mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
Keep the mobile terminal keyboard open while typing (#4469)
* fix(app): keep terminal keyboard focus stable Clearing the terminal buffer remounted the shared native input, triggering an IME hide/show cycle. Keep text replacement in place and make the composer explicitly reset its editor to preserve multiline layout clearing. * test(app): verify terminal keyboard continuity on Android Replace the new JSDOM regression with a real Android harness. It detects focused native input replacement and IME hide/show requests through terminal toolbar controls, and fails with the previous remount behavior.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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<EditingTextInputHandle>();
|
||||
|
||||
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<EditingTextInputHandle>();
|
||||
|
||||
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<EditingTextInputHandle>();
|
||||
|
||||
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<EditingTextInputHandle>();
|
||||
|
||||
act(() => {
|
||||
@@ -164,7 +164,7 @@ describe("EditingTextInputNative", () => {
|
||||
originalInput.focus();
|
||||
|
||||
act(() => {
|
||||
handleRef.current?.replaceText("");
|
||||
handleRef.current?.reset();
|
||||
handleRef.current?.blur();
|
||||
});
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
const inputRef = useRef<NativeInput | null>(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<EditingTextInputHandle, EditingTextIn
|
||||
getText: () => 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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -75,6 +75,11 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
input.setSelectionRange(selection.start, selection.end);
|
||||
}
|
||||
},
|
||||
reset: () => {
|
||||
textRef.current = "";
|
||||
const input = inputRef.current as WebTextInputElement | null;
|
||||
if (input && "value" in input) input.value = "";
|
||||
},
|
||||
getNativeRef: () => inputRef.current,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1240,7 +1240,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user