mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
fix(mobile): the page's Live input on Android echoes each letter as typed (OTA phase C follow-up) (#22958)
* test(mobile): the page's live input sends a composed word's letters as they are typed The reported shape, in a browser: `/tui` on an Android keyboard, where the `/` reached the terminal and `tui` did not until Enter. Android keyboards hold a composing region over the Latin word being typed, so on the page every input event mid-word carries `isComposing: true`, and the preedit mirror holds reported preedit with no settle timer. Native Android reports no range, so there each ASCII keystroke is sent as it is typed. Driven through Chromium's own IME path with an Android WebView user agent: the letters after the slash must arrive one by one, and a correction on commit must still erase and retype. The iPhone case is the guard that a composition off Android stays held. The hook case says the same thing without a browser. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page on Android reports no composing range, as native Android does `handleLiveInputChange` passed `nativeEvent.isComposing` straight to the preedit mirror. On the page that event is the DOM's, and an Android WebView marks the keyboard's composing region, which Samsung and other Latin keyboards keep over every word. The mirror treats a reported range as preedit that is not text yet, holds all of it with no settle timer, and Chromium fires no input event after compositionend, so the word sat in the field until the next keystroke or Enter. Native Android reports no range at all, and its fallback holds only a trailing non-ASCII run. The page on Android now reports the same: undefined. ASCII echoes on each key, a Hangul or kana run still settles on the timer, and a correction on commit still erases and retypes. iOS, where the range is the text system's marked text, is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the Android page among the platforms that report no composing range The mirror's fallback note listed only React Native Android; the page on Android now reports no range too. The reader's docblock becomes the one line it needs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -112,8 +112,11 @@ afterAll(async () => {
|
||||
* Settled is "the probe registered" or "the page reported a fault", because those are the two
|
||||
* outcomes and waiting only for the first turns the defect into a 60s timeout that names nothing.
|
||||
*/
|
||||
async function openProbe() {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
||||
async function openProbe({ userAgent } = {}) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 390, height: 844 },
|
||||
...(userAgent ? { userAgent } : {})
|
||||
})
|
||||
await page.addInitScript(installShellDouble, {
|
||||
version: bridgeVersion,
|
||||
sessionId: 'live-input-session',
|
||||
@@ -322,6 +325,104 @@ describeRender(
|
||||
await page.close()
|
||||
}, 300_000)
|
||||
|
||||
describe('under an Android keyboard, which composes every word it types', () => {
|
||||
// The OTA shell's WebView. Its keyboards hold a composing region over the Latin word being
|
||||
// typed, so every input event mid-word says `isComposing: true`; native Android reports no
|
||||
// range at all, and there each ASCII keystroke reaches the terminal as it is typed.
|
||||
const ANDROID_WEBVIEW_USER_AGENT =
|
||||
'Mozilla/5.0 (Linux; Android 16; Pixel 9 Pro Build/BP2A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/140.0.0.0 Mobile Safari/537.36'
|
||||
const sent = (page) => page.evaluate(() => globalThis.__orcaLiveInputProbe.sent())
|
||||
|
||||
/** One composition step through the browser's own IME path, which fires the DOM composition events. */
|
||||
async function compose(input, text) {
|
||||
await input.send('Input.imeSetComposition', {
|
||||
text,
|
||||
selectionStart: text.length,
|
||||
selectionEnd: text.length
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForField(page, value) {
|
||||
await page.waitForFunction(
|
||||
([id, expected]) => document.getElementById(id)?.value === expected,
|
||||
[LIVE_INPUT_FIELD_ID, value],
|
||||
{ timeout: 30_000, polling: 50 }
|
||||
)
|
||||
}
|
||||
|
||||
it('sends each letter of a composed word after a slash as it is typed', async () => {
|
||||
// The reported shape: `/tui` in a Codex terminal, where the `/` arrived and `tui` did not
|
||||
// until Enter. The keyboard commits `/` outright and opens a composition for the letters.
|
||||
const { errors, page } = await openProbe({ userAgent: ANDROID_WEBVIEW_USER_AGENT })
|
||||
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
|
||||
const input = await page.context().newCDPSession(page)
|
||||
await page.keyboard.type('/')
|
||||
await waitForField(page, '/')
|
||||
|
||||
const afterEachStep = []
|
||||
for (const text of ['t', 'tu', 'tui']) {
|
||||
await compose(input, text)
|
||||
await waitForField(page, `/${text}`)
|
||||
afterEachStep.push(await sent(page))
|
||||
}
|
||||
await input.send('Input.insertText', { text: 'tui' })
|
||||
await waitForField(page, '/tui')
|
||||
|
||||
expect(afterEachStep).toEqual([
|
||||
['/', 't'],
|
||||
['/', 't', 'u'],
|
||||
['/', 't', 'u', 'i']
|
||||
])
|
||||
expect(await sent(page)).toEqual(['/', 't', 'u', 'i'])
|
||||
expect(errors).toEqual([])
|
||||
await page.close()
|
||||
}, 300_000)
|
||||
|
||||
it('erases and retypes a word the keyboard corrects when it commits', async () => {
|
||||
const { errors, page } = await openProbe({ userAgent: ANDROID_WEBVIEW_USER_AGENT })
|
||||
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
|
||||
const input = await page.context().newCDPSession(page)
|
||||
for (const text of ['t', 'te', 'teh']) {
|
||||
await compose(input, text)
|
||||
await waitForField(page, text)
|
||||
}
|
||||
|
||||
await input.send('Input.insertText', { text: 'the' })
|
||||
await waitForField(page, 'the')
|
||||
|
||||
await page.waitForFunction(
|
||||
() => globalThis.__orcaLiveInputProbe.sent().length === 4,
|
||||
undefined,
|
||||
{
|
||||
timeout: 30_000,
|
||||
polling: 50
|
||||
}
|
||||
)
|
||||
expect(await sent(page)).toEqual(['t', 'e', 'h', '\u007f\u007fhe'])
|
||||
expect(errors).toEqual([])
|
||||
await page.close()
|
||||
}, 300_000)
|
||||
|
||||
it("still holds a composition off Android, where it is the text system's marked text", async () => {
|
||||
// The guard: an iOS WebView composes only what native iOS marks, pinyin before conversion
|
||||
// among it, and that is not text yet on either side of the bridge.
|
||||
const { errors, page } = await openProbe({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
|
||||
})
|
||||
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
|
||||
const input = await page.context().newCDPSession(page)
|
||||
for (const text of ['n', 'ni']) {
|
||||
await compose(input, text)
|
||||
await waitForField(page, text)
|
||||
}
|
||||
|
||||
expect(await sent(page)).toEqual([])
|
||||
expect(errors).toEqual([])
|
||||
await page.close()
|
||||
}, 300_000)
|
||||
})
|
||||
|
||||
describe('in buffered mode, where the field holds the draft until Enter', () => {
|
||||
const bufferedValue = (page) =>
|
||||
page.evaluate((id) => document.getElementById(id)?.value ?? null, BUFFERED_FIELD_ID)
|
||||
|
||||
@@ -21,9 +21,10 @@ export type TerminalLiveMirrorStep = {
|
||||
* ever classified. That is what makes this hold for input methods nobody tested.
|
||||
*
|
||||
* `composing` is undefined only where the platform reports no range at all —
|
||||
* today React Native Android. The fallback holds the trailing non-ASCII run: a
|
||||
* conversion IME always leaves one and ASCII keeps its zero-latency echo. It
|
||||
* enumerates nothing, and it cannot see an ASCII preedit — only a report can.
|
||||
* today React Native Android and the page on Android. The fallback holds the
|
||||
* trailing non-ASCII run: a conversion IME always leaves one and ASCII keeps its
|
||||
* zero-latency echo. It enumerates nothing, and it cannot see an ASCII preedit —
|
||||
* only a report can.
|
||||
*/
|
||||
function heldPreeditLength(
|
||||
fieldCodePoints: readonly string[],
|
||||
|
||||
@@ -121,6 +121,7 @@ function createTerminalLiveInputCommitHarness({
|
||||
describe('terminal live input commit hook', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('Given Hangul composition and no marked-text report When steps arrive Then no jamo leaks', async () => {
|
||||
@@ -168,6 +169,41 @@ describe('terminal live input commit hook', () => {
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given an Android WebView composing each word When letters follow a slash Then each reaches the terminal as typed', async () => {
|
||||
// Given: the page's field event is the DOM's, and an Android keyboard composes Latin words
|
||||
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' })
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
changeLiveInput(handlers, '/', false)
|
||||
await vi.waitFor(() => expect(sent).toHaveLength(1))
|
||||
for (const fieldText of ['/t', '/tu', '/tui']) {
|
||||
changeLiveInput(handlers, fieldText, true)
|
||||
// Each keystroke on its own, so a held letter cannot hide inside a later batch.
|
||||
await vi.waitFor(() => expect(sent).toHaveLength(fieldText.length))
|
||||
}
|
||||
|
||||
// Then: what native Android sends for the same keys, which report no range there
|
||||
await vi.waitFor(() => expect(sent).toEqual(['/', 't', 'u', 'i']))
|
||||
})
|
||||
|
||||
it('Given an Android WebView composing Hangul When steps arrive Then the non-ASCII run still settles on the timer', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' })
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
for (const fieldText of ['ㅎ', '하', '한']) {
|
||||
changeLiveInput(handlers, fieldText, true)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given an iOS pinyin preedit When accessory Backspace edits it Then only the candidate reaches the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -27,6 +27,16 @@ type TerminalLiveInputChangeEvent = {
|
||||
}
|
||||
}
|
||||
|
||||
/** Android keyboards compose every Latin word; report no range, as native Android does. */
|
||||
function reportedLiveInputComposing(
|
||||
nativeEvent: TerminalLiveInputChangeEvent['nativeEvent']
|
||||
): boolean | undefined {
|
||||
if (globalThis.navigator?.userAgent?.includes('Android')) {
|
||||
return undefined
|
||||
}
|
||||
return nativeEvent.isComposing
|
||||
}
|
||||
|
||||
type TerminalLiveInputCommitOptions<TTabType extends string> = {
|
||||
readonly activeHandle: string | null
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
@@ -148,7 +158,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
void applyLiveInputMirror(
|
||||
activeHandle,
|
||||
normalizeTerminalTextInput(nativeEvent.text),
|
||||
nativeEvent.isComposing
|
||||
reportedLiveInputComposing(nativeEvent)
|
||||
)
|
||||
},
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user