fix(mobile): let a second tap on inert transcript close the keyboard

Every terminal tap re-focuses the hidden textarea, so once the on-screen keyboard
is open the only way to close it is the accessory bar's dismiss chevron. Tapping
the transcript to get the screen back is the obvious gesture and it did nothing.

A tap on INERT content with the keyboard already up now dismisses it. Nothing
else claims that gesture: an inert row has no action to trigger, so by that point
the tap has already done its only other job (the mouse report).

Scoped to 'content' ON PURPOSE. The prompt row ('input') keeps
focus-then-position, so a second tap there still places the caret — that is real
capability and trading it away would be a worse deal than the bug. A separate
test pins it rather than leaving it to the reader.

Actionable rows are unchanged: readbacks, "esc to interrupt" status rows and menu
selections still blur via _isActionableMobileTerminalTap, which runs first.

`keeps the hidden keyboard input focused after an inert Claude transcript tap`
asserted the OLD behaviour and is renamed and inverted, since revising that
behaviour is the point of this change. Its setup already focused the terminal
before tapping, so it was always exercising the second-tap case.

test/terminal-touch-tap.test.ts: 28 tests. The two new ones fail on master —
`closes the keyboard on a second tap of INERT transcript content` behaviourally,
by asserting blur where master re-focuses.

test/mobile/keyboard.test.ts: 51 tests, 5 failed | 46 passed — the same five
pre-existing failures as master, untouched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
lior
2026-08-10 20:31:38 +03:00
co-authored by Claude Opus 5
parent 26416f98de
commit 35f8f9d19f
3 changed files with 57 additions and 2 deletions
+14
View File
@@ -3595,6 +3595,20 @@ Object.assign(CodemanApp.prototype, {
// A synthetic xterm click can focus its helper textarea. Blur after the
// report so collapsing a readback never opens or retains the keyboard.
this._blurMobileTerminalInput();
} else if (intent === 'content' && startedWithTerminalFocus) {
// Tapping INERT transcript with the keyboard already up closes it.
//
// Every terminal tap re-focuses, so once the keyboard is open the only way
// to close it is the accessory bar's dismiss chevron. Tapping the
// transcript to get the screen back is the obvious gesture, and nothing
// else claims it: an inert row has no action to trigger, so by this point
// the tap has already done its only other job (the mouse report above).
//
// Scoped to 'content' ON PURPOSE. The prompt row ('input') keeps
// focus-then-position, so a second tap there still places the caret —
// pinned by "keeps the first prompt tap focus-only so it cannot activate a
// CLI row". Toggling there would trade away real capability.
this._blurMobileTerminalInput();
} else {
this._focusMobileTerminalInput();
}
+6 -2
View File
@@ -890,7 +890,7 @@ describe('Virtual Keyboard', () => {
expect(state.sentInputs[0]).toMatch(/^\x1b\[<0;\d+;1M\x1b\[<0;\d+;1m$/);
});
it('keeps the hidden keyboard input focused after an inert Claude transcript tap', async () => {
it('toggles the keyboard shut on a second inert Claude transcript tap', async () => {
const point = await page.evaluate(async () => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-claude-transcript-tap-test';
@@ -941,8 +941,12 @@ describe('Virtual Keyboard', () => {
await page.touchscreen.tap(point!.x, point!.y);
// The setup above leaves the terminal focused, so this tap is the SECOND
// one on an inert row — the case that now closes the keyboard. Previously
// it re-focused, which left the accessory bar's chevron as the only way to
// dismiss. The prompt row is unaffected and still positions the caret.
const activeClass = await page.evaluate(() => document.activeElement?.className);
expect(activeClass).toContain('xterm-helper-textarea');
expect(activeClass).not.toContain('xterm-helper-textarea');
});
it('prevents Claude subagent status taps from opening the hidden keyboard input', async () => {
+37
View File
@@ -199,6 +199,43 @@ describe('terminal touch tap mouse guard', () => {
expect(app.terminal.focus).toHaveBeenCalledOnce();
});
it('closes the keyboard on a second tap of INERT transcript content', () => {
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4);
app._sendInputAsync = vi.fn();
// Keyboard DOWN: the tap opens it.
setActiveElement(null);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, false)).toBe('content');
expect(app.terminal.focus).toHaveBeenCalledOnce();
expect(app.terminal.textarea.blur).not.toHaveBeenCalled();
// Keyboard UP on the same inert row: the tap closes it.
app.terminal.focus.mockClear();
setActiveElement(app.terminal.textarea);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, true)).toBe('content');
expect(app.terminal.textarea.blur).toHaveBeenCalledOnce();
expect(app.terminal.focus).not.toHaveBeenCalled();
});
it('keeps the prompt row focusing rather than toggling, so the caret can still be placed', () => {
// The toggle is scoped to 'content' on purpose: a second tap on the PROMPT
// must still position the cursor. This is the guarantee that makes the
// change safe to make, so it is pinned separately.
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ask', ''], 4);
app._sendInputAsync = vi.fn();
setActiveElement(app.terminal.textarea);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, true)).toBe('input');
expect(app.terminal.textarea.blur).not.toHaveBeenCalled();
expect(app.terminal.focus).toHaveBeenCalledOnce();
});
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
const { app } = loadTerminalUiHarness();
const { element, dispatch } = createElementHarness();