diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index bd4bf1475b..b341a2d80e 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -35,6 +35,18 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile [native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace. +For Android keyboard continuity, run the current checkout in the app, open an idle terminal +with an empty prompt, hide its keyboard, and run: + +```bash +ANDROID_SERIAL=emulator-5554 node packages/app/e2e/mobile/terminal-keyboard/android.mjs +``` + +Use a real docked software keyboard. The harness taps Ctrl, Esc, and Enter and checks Android's +focused input identity and IME hide/show events. It saves screenshots and logs under +`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo` +to test an installed production build. It never submits a chat message. + When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts. ## Maestro compatibility diff --git a/packages/app/e2e/mobile/terminal-keyboard/android.mjs b/packages/app/e2e/mobile/terminal-keyboard/android.mjs new file mode 100644 index 0000000000..9a9f1962ae --- /dev/null +++ b/packages/app/e2e/mobile/terminal-keyboard/android.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +// Open an idle terminal with an empty prompt and hide its keyboard before running. +// The app must already run the checkout/build being verified against a real daemon. +const appId = process.env.PASEO_TERMINAL_KEYBOARD_APP_ID ?? "sh.paseo.debug"; +const serial = process.env.ANDROID_SERIAL ?? "emulator-5554"; +const artifacts = resolve(".dev/agent-device-artifacts/terminal-keyboard-android"); +const session = "terminal-keyboard-android"; +const env = { + ...process.env, + AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-terminal-keyboard"), +}; +mkdirSync(artifacts, { recursive: true }); + +function run(binary, args) { + return execFileSync(binary, args, { env, encoding: "utf8", timeout: 30_000 }); +} +function ad(...args) { + return run("agent-device", [...args, "--session", session]); +} +function adb(...args) { + return run("adb", ["-s", serial, ...args]); +} +function keyboardState() { + const state = adb("shell", "dumpsys", "input_method"); + const view = state.match(/^ mServedView=(.+)$/m)?.[1] ?? ""; + return { + shown: /^ mInputShown=true$/m.test(state), + // The native object identity survives layout/flag changes but not a remount. + input: view.match(/ReactEditText\{(\w+)/)?.[1] ?? null, + }; +} +async function waitForKeyboard(shown) { + let stable = 0; + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const state = keyboardState(); + stable = state.shown === shown ? stable + 1 : 0; + if (stable === 3) return state; + await setTimeout(100); + } + throw new Error(`Keyboard did not settle at shown=${shown}`); +} +function keyboardShift() { + const state = adb("shell", "dumpsys", "window"); + const ime = state.match(/type=ime frame=\[0,(\d+)\].*visible=true/); + const navigation = state.match(/type=navigationBars frame=\[0,(\d+)\]/); + assert.ok(ime && navigation, "Expected a docked keyboard and navigation bar"); + return Number(navigation[1]) - Number(ime[1]); +} +function imeEvents() { + return adb("logcat", "-d", "-v", "threadtime", "ImeTracker:I", "*:S") + .split("\n") + .filter((line) => line.includes(`${appId}:`)); +} +function tap(rect, shift) { + ad( + "press", + String(Math.round(rect.x + rect.width / 2)), + String(Math.round(rect.y + rect.height / 2 - shift)), + ); +} + +let toggle; +try { + ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime"); + assert.equal(keyboardState().shown, false, "Start with the terminal keyboard hidden"); + // Accessibility snapshots can hang with a real IME open. Measure the controls + // while hidden, then project them by Android's actual docked-keyboard inset. + const snapshot = JSON.parse(ad("snapshot", "-i", "--json")); + const rectFor = (id) => { + const node = snapshot.data.nodes.find((candidate) => candidate.identifier === id); + assert.ok(node?.rect, `Open a terminal first: missing ${id}`); + return node.rect; + }; + toggle = rectFor("terminal-keyboard-toggle"); + const controls = ["ctrl", "ctrl", "esc", "enter"].map((key) => ({ + key, + rect: rectFor(`terminal-key-${key}`), + })); + tap(toggle, 0); + const baseline = await waitForKeyboard(true); + assert.notEqual(baseline.input, null, "Expected the native terminal input to own the IME"); + const shift = keyboardShift(); + const before = new Set(imeEvents()); + ad("screenshot", resolve(artifacts, "before.png")); + + for (const { key, rect } of controls) { + tap(rect, shift); + const after = await waitForKeyboard(true); + const events = imeEvents().filter((line) => !before.has(line)); + writeFileSync(resolve(artifacts, "ime-events.log"), events.join("\n")); + writeFileSync( + resolve(artifacts, "focus.json"), + JSON.stringify({ key, baseline, after }, null, 2), + ); + assert.equal(after.input, baseline.input, `${key} replaced the focused native input`); + assert.deepEqual( + events.filter((line) => /onRequestHide|onRequestShow/.test(line)), + [], + `${key} cycled the software keyboard`, + ); + } + ad("screenshot", resolve(artifacts, "after.png")); + console.log("PASS: Ctrl, Esc and Enter preserve the native input without IME hide/show requests"); +} finally { + if (toggle && keyboardState().shown) { + tap(toggle, keyboardShift()); + await waitForKeyboard(false); + } + ad("close"); + run("agent-device", ["daemon", "stop", "--clean"]); +} diff --git a/packages/app/src/components/ui/text-input/text-input.native.test.tsx b/packages/app/src/components/ui/text-input/text-input.native.test.tsx index 2b04a84c63..4fa604d657 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.test.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.test.tsx @@ -92,7 +92,7 @@ describe("EditingTextInputNative", () => { expect(handleRef.current?.getText()).toBe(""); }); - it("replaces the native input when clearing text", () => { + it("replaces the native input when resetting the editor", () => { const handleRef = createRef(); act(() => { @@ -107,13 +107,13 @@ describe("EditingTextInputNative", () => { const grownInput = container?.querySelector("input"); act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); }); expect(container?.querySelector("input")).not.toBe(grownInput); }); - it("restores focus after replacing a cleared native input", () => { + it("restores focus after replacing a reset native input", () => { const handleRef = createRef(); act(() => { @@ -125,13 +125,13 @@ describe("EditingTextInputNative", () => { originalInput.focus(); act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); }); expect(document.activeElement).toBe(container?.querySelector("input")); }); - it("focuses the replacement input when focus is requested before a cleared input remounts", () => { + it("focuses the replacement input when focus is requested before an editor reset remounts", () => { const handleRef = createRef(); act(() => { @@ -142,7 +142,7 @@ describe("EditingTextInputNative", () => { const originalFocus = vi.spyOn(originalInput, "focus"); act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); handleRef.current?.focus(); }); @@ -152,7 +152,7 @@ describe("EditingTextInputNative", () => { expect(document.activeElement).toBe(replacementInput); }); - it("drops a pending focus restore when blur is requested before a cleared input remounts", () => { + it("drops a pending focus restore when blur is requested before an editor reset remounts", () => { const handleRef = createRef(); act(() => { @@ -164,7 +164,7 @@ describe("EditingTextInputNative", () => { originalInput.focus(); act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); handleRef.current?.blur(); }); diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx index 074367a1c9..16feafabcb 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -35,7 +35,8 @@ export const EditingTextInput = forwardRef(null); const initialTextRef = useRef(initialValue); const textRef = useRef(initialTextRef.current); - // Clearing swaps the native input for a fresh instance (see replaceText). + // Resetting the editor swaps the native input to reset intrinsic multiline + // sizing. Text replacement (including an empty buffer) keeps the input mounted. // Until React commits that swap, `inputRef` still points at the doomed // instance: focusing it asks Android for the keyboard and then tears the // focused view down, which cancels the show. Fabric also runs view @@ -74,27 +75,31 @@ export const EditingTextInput = forwardRef textRef.current, replaceText: (nextText, selection) => { textRef.current = nextText; - if (nextText === "") { - const autoFocus = inputRef.current?.isFocused?.() ?? false; - if (inputRef.current?.replaceText) { - inputRef.current.replaceText(nextText, selection); - } else { - inputRef.current?.clear?.(); - } - isAwaitingReplacementRef.current = true; - setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); - return; - } if (inputRef.current?.replaceText) { inputRef.current.replaceText(nextText, selection); return; } + if (nextText === "") { + inputRef.current?.clear?.(); + return; + } inputRef.current?.setNativeProps?.({ text: nextText, ...(selection ? { selection } : {}), }); if (selection) inputRef.current?.setSelection?.(selection.start, selection.end); }, + reset: () => { + textRef.current = ""; + const autoFocus = inputRef.current?.isFocused?.() ?? false; + if (inputRef.current?.replaceText) { + inputRef.current.replaceText(""); + } else { + inputRef.current?.clear?.(); + } + isAwaitingReplacementRef.current = true; + setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); + }, getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current, })); diff --git a/packages/app/src/components/ui/text-input/text-input.web.tsx b/packages/app/src/components/ui/text-input/text-input.web.tsx index 82f2f47c07..100ae185df 100644 --- a/packages/app/src/components/ui/text-input/text-input.web.tsx +++ b/packages/app/src/components/ui/text-input/text-input.web.tsx @@ -75,6 +75,11 @@ export const EditingTextInput = forwardRef { + textRef.current = ""; + const input = inputRef.current as WebTextInputElement | null; + if (input && "value" in input) input.value = ""; + }, getNativeRef: () => inputRef.current, })); diff --git a/packages/app/src/components/ui/text-input/types.ts b/packages/app/src/components/ui/text-input/types.ts index 49a0f60ff6..6a1daacbcc 100644 --- a/packages/app/src/components/ui/text-input/types.ts +++ b/packages/app/src/components/ui/text-input/types.ts @@ -7,6 +7,8 @@ export interface EditingTextInputHandle { isFocused(): boolean; getText(): string; replaceText(text: string, selection?: { start: number; end: number }): void; + /** Clear the editor and reset its intrinsic layout, preserving focus intent. */ + reset(): void; getNativeRef(): unknown; } diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index fc8a260cfb..27bd9b8a92 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1240,7 +1240,11 @@ export const MessageInput = forwardRef( valueRef.current = nextText; updateLiveTextPresence(nextText); selectionRef.current = selection ?? { start: nextText.length, end: nextText.length }; - textInputRef.current?.replaceText(nextText, selection); + if (nextText === "") { + textInputRef.current?.reset(); + } else { + textInputRef.current?.replaceText(nextText, selection); + } onChangeText(nextText); }, [onChangeText, updateComposerHeightForText, updateLiveTextPresence], @@ -1292,7 +1296,11 @@ export const MessageInput = forwardRef( updateComposerHeightForText?.(valueRef.current, textReplacement.text); valueRef.current = textReplacement.text; updateLiveTextPresence(textReplacement.text); - textInputRef.current?.replaceText(textReplacement.text); + if (textReplacement.text === "") { + textInputRef.current?.reset(); + } else { + textInputRef.current?.replaceText(textReplacement.text); + } }, [textReplacement, updateComposerHeightForText, updateLiveTextPresence]); useEffect(() => { diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx index 91dec513e6..6e70fa3edf 100644 --- a/packages/app/src/composer/input/text-input.web.browser.test.tsx +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -82,6 +82,20 @@ afterEach(() => { }); describe("ComposerTextInput web IME composition", () => { + it("resets the composer text while preserving focus", () => { + const mounted = mountInput(ignoreTextChange); + act(() => { + mounted.inputRef.current?.focus(); + typeFromIme(mounted.textarea, "line one\nline two"); + }); + + act(() => mounted.inputRef.current?.reset()); + + expect(mounted.textarea.value).toBe(""); + expect(mounted.inputRef.current?.getText()).toBe(""); + expect(document.activeElement).toBe(mounted.textarea); + }); + it("keeps locally typed text when its parent rerenders with a stale value", () => { const recorder = createTextRecorder(); const mounted = mountInput(recorder.onChangeText);