mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(mobile): preserve native IME composition
This commit is contained in:
@@ -5012,7 +5012,7 @@ export default function SessionScreen() {
|
||||
ref={liveInputRef}
|
||||
style={styles.liveInputCapture}
|
||||
value={liveInputCapture}
|
||||
onChangeText={handleLiveInputChange}
|
||||
onChange={handleLiveInputChange}
|
||||
onKeyPress={handleLiveInputKeyPress}
|
||||
onSubmitEditing={handleLiveInputSubmit}
|
||||
placeholder=""
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
--- a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
@@ -701,7 +701,8 @@
|
||||
- (TextInputEventEmitter::Metrics)_textInputMetrics
|
||||
{
|
||||
return {
|
||||
.text = RCTStringFromNSString(_backedTextInputView.attributedText.string),
|
||||
+ .isComposing = _backedTextInputView.markedTextRange != nil,
|
||||
.selectionRange = [self _selectionRange],
|
||||
.eventCount = static_cast<int>(_mostRecentEventCount),
|
||||
.contentOffset = RCTPointFromCGPoint(_backedTextInputView.contentOffset),
|
||||
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
@@ -20,6 +20,10 @@ static jsi::Value textInputMetricsPayload(
|
||||
"text",
|
||||
jsi::String::createFromUtf8(runtime, textInputMetrics.text));
|
||||
|
||||
+ if (textInputMetrics.isComposing.has_value()) {
|
||||
+ payload.setProperty(runtime, "isComposing", *textInputMetrics.isComposing);
|
||||
+ }
|
||||
+
|
||||
payload.setProperty(runtime, "target", textInputMetrics.target);
|
||||
|
||||
payload.setProperty(runtime, "eventCount", textInputMetrics.eventCount);
|
||||
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
+#include <optional>
|
||||
+
|
||||
#include <react/renderer/attributedstring/AttributedString.h>
|
||||
#include <react/renderer/components/view/ViewEventEmitter.h>
|
||||
|
||||
@@ -18,6 +20,7 @@ class TextInputEventEmitter : public ViewEventEmitter {
|
||||
|
||||
struct Metrics {
|
||||
std::string text;
|
||||
+ std::optional<bool> isComposing;
|
||||
AttributedString::Range selectionRange;
|
||||
// ScrollView-like metrics
|
||||
Size contentSize;
|
||||
Generated
+243
-212
File diff suppressed because it is too large
Load Diff
@@ -3,3 +3,6 @@ allowBuilds:
|
||||
|
||||
overrides:
|
||||
xcode>uuid: 11.1.1
|
||||
|
||||
patchedDependencies:
|
||||
react-native@0.83.9: patches/react-native@0.83.9.patch
|
||||
|
||||
@@ -117,6 +117,14 @@ function createTerminalLiveInputCommitHarness({
|
||||
}
|
||||
}
|
||||
|
||||
function changeLiveInput(
|
||||
handlers: TerminalLiveInputCommitHarness['handlers'],
|
||||
text: string,
|
||||
isComposing?: boolean
|
||||
): void {
|
||||
handlers.handleLiveInputChange({ nativeEvent: { isComposing, text } })
|
||||
}
|
||||
|
||||
describe('terminal live input commit hook', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
@@ -129,7 +137,7 @@ describe('terminal live input commit hook', () => {
|
||||
|
||||
// When: ㅎ→하→한→한ㄱ→한그→한글 (no settle pause between steps)
|
||||
for (const fieldText of ['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) {
|
||||
handlers.handleLiveInputChange(fieldText)
|
||||
changeLiveInput(handlers, fieldText)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
|
||||
@@ -141,7 +149,7 @@ describe('terminal live input commit hook', () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
@@ -154,12 +162,12 @@ describe('terminal live input commit hook', () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('하')
|
||||
changeLiveInput(handlers, '하')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['하']))
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
@@ -169,7 +177,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
@@ -192,7 +200,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
@@ -208,19 +216,47 @@ describe('terminal live input commit hook', () => {
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('a')
|
||||
handlers.handleLiveInputChange('ab')
|
||||
changeLiveInput(handlers, 'a', false)
|
||||
changeLiveInput(handlers, 'ab', false)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
|
||||
})
|
||||
|
||||
it('Given provisional marked text When its change arrives Then echoes without sending', () => {
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
changeLiveInput(handlers, 'かな', true)
|
||||
|
||||
expect(captures).toEqual(['かな'])
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given marked text When composition ends Then sends the committed text exactly once', async () => {
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
changeLiveInput(handlers, 'かな', true)
|
||||
|
||||
changeLiveInput(handlers, 'かな', false)
|
||||
|
||||
await vi.waitFor(() => expect(sent).toEqual(['かな']))
|
||||
})
|
||||
|
||||
it('Given active composition When native control events arrive Then keeps them inside the IME', () => {
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
changeLiveInput(handlers, 'かな', true)
|
||||
|
||||
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Tab' } })
|
||||
handlers.handleLiveInputSubmit()
|
||||
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given iOS smart-dash text When the change arrives Then the capture echoes the raw field text and the PTY gets normalized bytes', async () => {
|
||||
// Given
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When: iOS smart punctuation rewrote "--" into an en dash inside the field
|
||||
handlers.handleLiveInputChange('a–')
|
||||
changeLiveInput(handlers, 'a–')
|
||||
|
||||
// Then: writing "a--" back into the controlled value would kill an active
|
||||
// iOS dictation/IME session, so the capture must keep what iOS produced
|
||||
@@ -233,8 +269,8 @@ describe('terminal live input commit hook', () => {
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When: iOS dictation replaces its hypothesis as recognition refines
|
||||
handlers.handleLiveInputChange('high')
|
||||
handlers.handleLiveInputChange('hi there')
|
||||
changeLiveInput(handlers, 'high')
|
||||
changeLiveInput(handlers, 'hi there')
|
||||
|
||||
// Then: captures only echo the field; the mirror repairs the PTY with DELs
|
||||
expect(captures).toEqual(['high', 'hi there'])
|
||||
@@ -244,10 +280,10 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('한 ')
|
||||
changeLiveInput(handlers, '한 ')
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한 ']))
|
||||
@@ -256,7 +292,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
|
||||
@@ -269,7 +305,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
|
||||
@@ -279,12 +315,12 @@ describe('terminal live input commit hook', () => {
|
||||
expect(sent).toEqual(['한'])
|
||||
})
|
||||
|
||||
it('Given non-Hangul IME text When changes arrive Then mirrors immediately without a settle window', async () => {
|
||||
it('Given a change event without composition state When it arrives Then uses the existing mirror path', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('你好')
|
||||
changeLiveInput(handlers, '你好')
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好']))
|
||||
@@ -294,7 +330,7 @@ describe('terminal live input commit hook', () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
unmount()
|
||||
@@ -307,7 +343,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Backspace with field text When the key arrives Then edits locally without terminal bytes', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } })
|
||||
@@ -319,7 +355,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Tab with a held syllable When the key arrives Then commits the syllable before the tab bytes', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Tab' } })
|
||||
@@ -331,7 +367,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Hangul pending When the tab type lags to undefined Then keeps the composition state', async () => {
|
||||
// Given: '한' held while the active tab is still a terminal
|
||||
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When: the mobile tab list momentarily yields no active tab object
|
||||
setActiveSessionTabType(undefined)
|
||||
@@ -344,7 +380,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Hangul pending When the tab genuinely changes to non-terminal Then clears the composition state', async () => {
|
||||
// Given: '한' held while the active tab is still a terminal
|
||||
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When: the active tab actually becomes a non-terminal (chat) tab
|
||||
setActiveSessionTabType('chat')
|
||||
@@ -358,7 +394,7 @@ describe('terminal live input commit hook', () => {
|
||||
// Given: a stalled link — the mirror sends but the PTY never accepts (#6713 second defect)
|
||||
const { captures, handlers, sent, setConnected, setSendResult } =
|
||||
createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
handlers.handleLiveInputChange('XYZZY')
|
||||
changeLiveInput(handlers, 'XYZZY')
|
||||
await vi.waitFor(() => expect(sent).toEqual(['XYZZY']))
|
||||
|
||||
// When: the outage is finally detected, then the link recovers
|
||||
@@ -370,7 +406,7 @@ describe('terminal live input commit hook', () => {
|
||||
// 'XYZZY…' replayed and not DELs erasing PTY chars that never arrived
|
||||
expect(captures.at(-1)).toBe('')
|
||||
const sentBeforeRecovery = sent.length
|
||||
handlers.handleLiveInputChange('echo CLEANLINE')
|
||||
changeLiveInput(handlers, 'echo CLEANLINE')
|
||||
await vi.waitFor(() => expect(sent.slice(sentBeforeRecovery)).toEqual(['echo CLEANLINE']))
|
||||
})
|
||||
|
||||
@@ -380,7 +416,7 @@ describe('terminal live input commit hook', () => {
|
||||
const { handlers, sent, setConnected } = createTerminalLiveInputCommitHarness({
|
||||
sendResult: false
|
||||
})
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
setConnected(false)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, type RefObject } from 'react'
|
||||
import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
@@ -17,6 +17,13 @@ type TerminalLiveInputKeyPressEvent = {
|
||||
}
|
||||
}
|
||||
|
||||
type TerminalLiveInputChangeEvent = {
|
||||
readonly nativeEvent: {
|
||||
readonly isComposing?: boolean
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
|
||||
type TerminalLiveInputCommitOptions<TTabType extends string> = {
|
||||
readonly activeHandle: string | null
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
@@ -36,7 +43,7 @@ type TerminalLiveInputCommitHandlers = {
|
||||
readonly handleLiveInputAccessoryBytes: (
|
||||
input: TerminalLiveAccessoryInput
|
||||
) => Promise<TerminalLiveAccessoryInputCommitResult>
|
||||
readonly handleLiveInputChange: (text: string) => void
|
||||
readonly handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void
|
||||
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
|
||||
readonly handleLiveInputSubmit: () => void
|
||||
}
|
||||
@@ -53,9 +60,10 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
|
||||
const liveInputComposingRef = useRef(false)
|
||||
const {
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
clearPendingLiveInputCommit: clearPendingLiveInputMirror,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
pendingLiveInputHandleRef,
|
||||
@@ -70,6 +78,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
setLiveInputCapture
|
||||
})
|
||||
|
||||
const clearPendingLiveInputCommit = useCallback(() => {
|
||||
liveInputComposingRef.current = false
|
||||
clearPendingLiveInputMirror()
|
||||
}, [clearPendingLiveInputMirror])
|
||||
|
||||
useEffect(() => {
|
||||
// Why: what reached the PTY is unknowable across an outage — stale mirror state corrupts the first post-reconnect send.
|
||||
if (!connected) {
|
||||
@@ -113,7 +126,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
)
|
||||
|
||||
const handleLiveInputChange = useCallback(
|
||||
(text: string) => {
|
||||
({ nativeEvent: { isComposing, text } }: TerminalLiveInputChangeEvent) => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
clearPendingLiveInputCommit()
|
||||
return
|
||||
@@ -122,6 +135,10 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
// that differs from the native field text, so the controlled capture must
|
||||
// echo the field verbatim; only the PTY mirror sees normalized text.
|
||||
setLiveInputCapture(text)
|
||||
liveInputComposingRef.current = isComposing === true
|
||||
if (liveInputComposingRef.current) {
|
||||
return
|
||||
}
|
||||
applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text))
|
||||
},
|
||||
[
|
||||
@@ -135,7 +152,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
|
||||
const handleLiveInputKeyPress = useCallback(
|
||||
(event: TerminalLiveInputKeyPressEvent) => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
if (
|
||||
!activeHandle ||
|
||||
!liveInputTerminalHandles.has(activeHandle) ||
|
||||
liveInputComposingRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
|
||||
@@ -192,7 +213,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
})
|
||||
|
||||
const handleLiveInputSubmit = useCallback(() => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
if (
|
||||
!activeHandle ||
|
||||
!liveInputTerminalHandles.has(activeHandle) ||
|
||||
liveInputComposingRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
|
||||
Reference in New Issue
Block a user