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;