From 35f8f9d19fcd0994a2fee4a62633e46048ad01fb Mon Sep 17 00:00:00 2001 From: lior Date: Mon, 10 Aug 2026 20:31:38 +0300 Subject: [PATCH] fix(mobile): let a second tap on inert transcript close the keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/web/public/terminal-ui.js | 14 +++++++++++++ test/mobile/keyboard.test.ts | 8 +++++-- test/terminal-touch-tap.test.ts | 37 +++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index e3412fb8f..46676cb75 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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(); } diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index df0c4cd13..bdf2ee5d1 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -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 () => { diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 8562871f5..3b7551466 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -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();