fix(mobile): reconcile the two keyboard-dismiss paths (#279 + #280)

#279 and #280 auto-merge cleanly, but the merged result was red: neither
branch could see the other, and CI cannot see either, because the only test
covering #279 lives in test/mobile/** which test:ci excludes.

Two problems, both in #279's test:

1. The in-terminal case tapped the terminal's top-left corner, i.e. an inert
   transcript row, and asserted focus was retained. That is precisely the
   gesture #280 redefines, so #280 turned it red. Aim it at the PROMPT row
   instead: the one in-terminal tap whose outcome neither PR claims, so it
   still proves the #terminalContainer exemption without asserting the
   toggle's behaviour.

2. The "a real control is exempt" case was VACUOUS. It picked the first
   button measuring >8px, which is .welcome-ralph-link inside the welcome
   overlay hideWelcome() had already hidden: the rect still measures, but
   elementFromPoint at that point returns .xterm-screen, so the case tapped
   the TERMINAL and passed for the wrong reason. It only surfaced because
   #280 changed what a terminal tap does. Require the sampled point to
   actually resolve to the button, and fail loudly when no control is
   usable rather than silently asserting nothing.

Mutation-checked: removing the install, the #terminalContainer exemption,
the control exemption or the `if (moved) return` scroll guard each turns
the test red on its own. The control exemption had no coverage before.

Also fold the duplicated tap slop into one constant: initTerminal's
TAP_THRESHOLD now reads MOBILE_KEYBOARD_DISMISS_TAP_SLOP instead of
re-declaring 8, since a drift between them is exactly the bug the second
#279 commit fixed. And restore the comment the slop constant was inserted
into the middle of, which left "Regions where a tap must NOT dismiss"
sitting above the slop rather than the selector it documents.

test/mobile/keyboard.test.ts: 5 failed | 47 passed (52). Master is
5 failed | 46 passed (51) — the same five pre-existing failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-11 19:34:00 +02:00
parent 67f6ed3168
commit aa28ef048c
2 changed files with 44 additions and 10 deletions
+9 -4
View File
@@ -32,13 +32,16 @@
// short window, only the app's synthetic tap-to-position mouse event should
// reach xterm.
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
// Finger travel (px) still counted as a tap rather than a scroll. Shared by
// the terminal's own touch handling (TAP_THRESHOLD, initTerminal) and the
// keyboard-dismiss handler (_installMobileKeyboardDismiss), which MUST agree:
// a gesture the terminal treats as a scroll but the dismiss handler treats as
// a tap would close the keyboard mid-scroll and drop the composer.
const MOBILE_KEYBOARD_DISMISS_TAP_SLOP = 8;
// Regions where a tap must NOT dismiss the on-screen keyboard
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
// focus itself, and the accessory bar, which is built to be used while the
// keyboard is open.
// Finger travel (px) still counted as a tap for keyboard dismissal. Matches
// the terminal's own TAP_THRESHOLD so both agree on tap-vs-scroll.
const MOBILE_KEYBOARD_DISMISS_TAP_SLOP = 8;
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
'input',
'textarea',
@@ -675,7 +678,9 @@ Object.assign(CodemanApp.prototype, {
let touchStartY = 0;
let tapStartedWithTerminalFocus = false;
let tapStartIntentCache = null;
const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll
// px — ignore micro-drift to distinguish tap from scroll. Shared with the
// keyboard-dismiss handler so both classify the same gesture the same way.
const TAP_THRESHOLD = window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_TAP_SLOP;
container.addEventListener(
'touchstart',
(ev) => {
+35 -6
View File
@@ -633,10 +633,12 @@ describe('Virtual Keyboard', () => {
// document, and calling the internal helper would bypass the routing this
// test exists to check.
const result = await page.evaluate(async () => {
const tap = async (el: Element, travel = 0) => {
const sampleX = (rect: DOMRect) => Math.max(2, rect.left + Math.min(6, rect.width / 2));
const sampleY = (rect: DOMRect) => Math.max(2, rect.top + Math.min(6, rect.height / 2));
const tap = async (el: Element, travel = 0, point?: { x: number; y: number }) => {
const rect = el.getBoundingClientRect();
const x = Math.max(2, rect.left + Math.min(6, rect.width / 2));
const y = Math.max(2, rect.top + Math.min(6, rect.height / 2));
const x = point ? point.x : sampleX(rect);
const y = point ? point.y : sampleY(rect);
const target = document.elementFromPoint(x, y) || el;
const at = (cy: number) => new Touch({ identifier: 21, target, clientX: x, clientY: cy });
target.dispatchEvent(
@@ -686,13 +688,39 @@ describe('Virtual Keyboard', () => {
app._focusMobileTerminalInput();
const button = Array.from(document.querySelectorAll('button:not([disabled])')).find((candidate) => {
const rect = candidate.getBoundingClientRect();
return rect.width > 8 && rect.height > 8;
if (rect.width <= 8 || rect.height <= 8) return false;
// A rect is not enough. The welcome overlay is hidden by hideWelcome()
// above but its buttons still MEASURE, so a rect-only pick sampled a
// point the terminal actually owns — elementFromPoint returned
// .xterm-screen and this case tapped the terminal instead of a
// control, passing for the wrong reason. Require the sampled point to
// really resolve to this button.
const hit = document.elementFromPoint(sampleX(rect), sampleY(rect));
return !!hit && candidate.contains(hit);
});
const afterButton = button ? await tap(button) : 'no-visible-button';
// Inside the terminal, tap classification owns the decision.
// Inside the terminal, tap classification owns the decision, so this
// handler must keep its hands off. Aimed at the PROMPT row: that is the
// one in-terminal tap whose outcome belongs to nobody else, since an
// inert transcript row is claimed by the in-terminal dismiss toggle
// (`toggles the keyboard shut on a second inert Claude transcript tap`)
// and asserting focus there would be asserting that toggle's behaviour
// rather than this exemption. The guard still bites: the container's own
// touchend listener runs first and refocuses, so a missing
// #terminalContainer exemption would blur right back over it.
app._focusMobileTerminalInput();
const afterTerminal = await tap(document.querySelector('#terminalContainer')!);
const screen = app.terminal.element?.querySelector('.xterm-screen');
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
const screenRect = screen?.getBoundingClientRect();
const promptPoint =
screenRect && cell?.width && cell?.height
? {
x: screenRect.left + cell.width * 2,
y: screenRect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5),
}
: undefined;
const afterTerminal = await tap(document.querySelector('#terminalContainer')!, 0, promptPoint);
// A SCROLL also ends in touchend. Scrolling to read something while
// composing must not close the keyboard and drop the composer.
@@ -705,6 +733,7 @@ describe('Virtual Keyboard', () => {
expect(result.focusedBefore).toContain('xterm-helper-textarea');
// Red on master: the textarea keeps focus and the keyboard stays up.
expect(result.afterOutside).not.toContain('xterm-helper-textarea');
expect(result.afterButton).not.toBe('no-visible-button');
expect(result.afterButton).toContain('xterm-helper-textarea');
expect(result.afterTerminal).toContain('xterm-helper-textarea');
// A scroll ends in touchend too, and must NOT close the keyboard.