From 8ffa351340923b226bdc23167e57ade3cdd0ad4e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:58:36 -0700 Subject: [PATCH] fix(mobile): restore terminal input focus from blank-area taps --- .../terminal-webview-blank-tap.test.ts | 100 ++++++++++++++++++ .../terminal-webview-payload-hash.test.ts | 4 +- .../terminal-webview-tap-dispatch-injected.ts | 18 ++-- 3 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 mobile/src/terminal/terminal-webview-blank-tap.test.ts diff --git a/mobile/src/terminal/terminal-webview-blank-tap.test.ts b/mobile/src/terminal/terminal-webview-blank-tap.test.ts new file mode 100644 index 00000000000..fbf47fc6bb7 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-blank-tap.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from 'vitest' +import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected' + +function boot(selecting = false) { + const document = globalThis.document.implementation.createHTMLDocument() + document.body.innerHTML = + '
' + const container = document.getElementById('terminal-container')! + const surface = document.getElementById('surface')! + const posted = vi.fn() + const cellTap = vi.fn() + const select = vi.fn() + const cancel = vi.fn() + new Function( + 'document', + 'notify', + 'notifyTerminalSurfaceTap', + 'enterSelect', + 'onCancel', + ` + var surface = document.getElementById('surface'); + var selectionOverlay = document.getElementById('overlay'); + var handleStart = {}, handleEnd = {}, sel = {}, selMode = ${JSON.stringify(selecting ? 'select' : 'idle')}; + var tapCandidate = null, longPressTimer = null, longPressOrigin = null; + var LONG_PRESS_MS = 500, LONG_PRESS_SLOP = 10, TAP_SLOP = 24, TAP_MAX_MS = 700; + function viewportToCell() { throw new Error('Blank space has no cell'); } + function cancelSelect() { selMode = 'idle'; onCancel(); } + function handleDragMove() {} + function stopEdgeScroll() {} + ${TERMINAL_TAP_DISPATCH_JS} + ` + )(document, posted, cellTap, select, cancel) + const touch = (type: string, points: number[][], target = container) => { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'target', { value: target }) + Object.defineProperty(event, 'touches', { + value: points.map(([clientX, clientY], identifier) => ({ identifier, clientX, clientY })) + }) + document.dispatchEvent(event) + } + return { touch, posted, cellTap, select, cancel, surface } +} + +afterEach(() => vi.useRealTimers()) + +it('restores focus on blank container taps without resolving cells or links', () => { + const h = boot() + h.touch('touchstart', [[20, 300]]) + h.touch('touchend', []) + expect(h.posted.mock.calls).toEqual([[{ type: 'terminal-tap' }]]) + expect(h.cellTap).not.toHaveBeenCalled() + expect(h.select).not.toHaveBeenCalled() +}) + +it.each(['move', 'cancel', 'pinch', 'three-finger pinch', 'hold'])( + 'does not focus for a blank-space %s', + (kind) => { + vi.useFakeTimers() + const h = boot() + h.touch('touchstart', [[20, 300]]) + if (kind === 'move') { + h.touch('touchmove', [[20, 340]]) + } + if (kind === 'cancel') { + h.touch('touchcancel', []) + } + if (kind === 'pinch' || kind === 'three-finger pinch') { + h.touch('touchstart', [ + [20, 300], + [40, 300] + ]) + } + if (kind === 'three-finger pinch') { + h.touch('touchstart', [ + [20, 300], + [40, 300], + [60, 300] + ]) + } + if (kind === 'hold') { + vi.advanceTimersByTime(550) + } + h.touch('touchend', []) + expect(h.posted).not.toHaveBeenCalled() + expect(h.cellTap).not.toHaveBeenCalled() + expect(h.select).not.toHaveBeenCalled() + } +) + +it('dismisses selection first and only focuses on the next blank tap', () => { + const h = boot(true) + h.touch('touchstart', [[20, 300]]) + h.touch('touchend', []) + expect(h.cancel).toHaveBeenCalledOnce() + expect(h.posted).not.toHaveBeenCalled() + h.touch('touchstart', [[20, 300]]) + h.touch('touchend', []) + expect(h.posted).toHaveBeenCalledWith({ type: 'terminal-tap' }) +}) diff --git a/mobile/src/terminal/terminal-webview-payload-hash.test.ts b/mobile/src/terminal/terminal-webview-payload-hash.test.ts index f8bfa4bd134..1a947346d20 100644 --- a/mobile/src/terminal/terminal-webview-payload-hash.test.ts +++ b/mobile/src/terminal/terminal-webview-payload-hash.test.ts @@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html' // uncovered region ships silently. A diff here means the emitted WebView source changed — // update these values only when that change is deliberate, and only after checking the // document still runs. Refactors that merely move slice boundaries must leave them alone. -const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608' -const EXPECTED_LENGTH = 729776 +const EXPECTED_SHA256 = 'cb9d1d764ad765e1933ffce87f546ad8589928613da36bbf688992bb82b15eb8' +const EXPECTED_LENGTH = 730168 describe('terminal WebView payload', () => { it('composes the expected document', () => { diff --git a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts b/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts index 23036f84dfd..85ae622f3a8 100644 --- a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts +++ b/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts @@ -57,12 +57,13 @@ export const TERMINAL_TAP_DISPATCH_JS = ` var onHandle = target === handleStart || target === handleEnd; var inOverlay = targetInside(target, selectionOverlay); var inSurface = targetInside(target, surface); + var inBlankContainer = target === document.getElementById('terminal-container'); // Why: clear any stale tap candidate up front; only a fresh single-finger // surface touch (below) re-arms it, so handle drags / pinches / dismiss // taps never resolve as a link tap on touchend. tapCandidate = null; - if (e.touches.length === 2) { + if (e.touches.length >= 2) { // pinch latch if (selMode === 'select') { notify({ type: 'mobile-clip-cancel-by-pinch' }); @@ -89,7 +90,7 @@ export const TERMINAL_TAP_DISPATCH_JS = ` return; } - if (inSurface && selMode === 'select') { + if ((inSurface || inBlankContainer) && selMode === 'select') { // Why: tap-to-dismiss matches native iOS/Android — touching outside the // selection clears it. We cancel immediately and latch to 'surface' so // the same gesture still drives scroll/pan without a second touch. @@ -99,11 +100,11 @@ export const TERMINAL_TAP_DISPATCH_JS = ` return; } - if (inSurface) { + if (inSurface || inBlankContainer) { dispatch.mode = 'surface'; dispatch.touchId = t.identifier; - tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }; - armLongPress(t); + tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier, blank: inBlankContainer }; + if (inSurface) armLongPress(t); } }, { capture: true, passive: false }); @@ -163,7 +164,12 @@ export const TERMINAL_TAP_DISPATCH_JS = ` selMode !== 'select' && Date.now() - tapCandidate.t <= TAP_MAX_MS ) { - notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true); + if (tapCandidate.blank) { + // Blank space has no terminal cell, link, or mouse target. + if (Date.now() - tapCandidate.t < LONG_PRESS_MS) notify({ type: 'terminal-tap' }); + } else { + notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true); + } } clearLongPress(); tapCandidate = null;