From 9535edc367a69a018a4b8957565a0fb965a52230 Mon Sep 17 00:00:00 2001 From: Teigen Date: Mon, 6 Jul 2026 16:35:30 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(mobile):=20restore=20tap-to-position=20?= =?UTF-8?q?cursor=20after=20master=20merge=20=E2=80=94=20hand-encode=20SGR?= =?UTF-8?q?=20when=20server=20strips=20mouse=20DECSETs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.1.7 (3172bef, arrived via the master merge) strips mouse-tracking DECSET sequences from claude/codex/gemini output so the wheel keeps scrolling scrollback. Side effect: the browser xterm's mouseTrackingMode is permanently 'none' for those sessions, and the mobile touchend tap branch gates its synthetic click on exactly that mode — so tap-to-position-cursor silently died. Fix: when tracking reads 'none' but the session mode is one the server strips (claude/codex/gemini — the PTY-side TUI still has tracking ON), encode the SGR press+release report directly from the touch point and send it to the PTY, bypassing xterm's mouse encoder. No DOM click is dispatched, so xterm's local selection cannot trigger either. Tests: 3 new cases in test/terminal-touch-tap.test.ts (SGR encoding, grid clamping, shell-mode exclusion); verified E2E via Playwright iPhone emulation against both a stripped-stream instance and the production bundle. --- src/web/public/terminal-ui.js | 33 ++++++++++++++++++++++ test/terminal-touch-tap.test.ts | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d44e5dbf..326b818d 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -452,6 +452,15 @@ Object.assign(CodemanApp.prototype, { } if (touch && mouseTrackingOn) { this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); + } else if (touch && this._sessionUsesServerMouseStrip()) { + // The server strips mouse-tracking DECSETs from claude/codex/gemini + // output (isAltScreenStripMode, session.ts) so the wheel keeps + // scrolling scrollback — which leaves THIS xterm permanently at + // mouseTrackingMode 'none' even though the TUI on the PTY side has + // tracking ON and still understands SGR reports. Encode the report + // ourselves and send it straight to the PTY: no DOM click is + // dispatched, so xterm's local selection can't trigger either. + this._sendSyntheticSgrTap(touch.clientX, touch.clientY); } this._syncMobileHelperTextareaToCursor(); // Route subsequent typing to the right place: keep the CJK input @@ -2073,6 +2082,30 @@ Object.assign(CodemanApp.prototype, { } }, + // Mirror of the server's isAltScreenStripMode (session.ts): session modes whose + // output stream has mouse-tracking DECSET sequences stripped before reaching the + // browser. For these, xterm's live mouseTrackingMode is useless as a gate — the + // PTY-side TUI keeps tracking enabled, we just never see the enable sequence. + _sessionUsesServerMouseStrip() { + const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; + return mode === 'claude' || mode === 'codex' || mode === 'gemini'; + }, + + // Encode a tap as an SGR mouse report (press + release at button 0) and send it + // to the PTY directly, bypassing xterm's mouse encoder. Column/row are derived + // from the touch point the same way xterm maps a click: offset inside + // .xterm-screen divided by the rendered cell size, 1-based, clamped to the grid. + _sendSyntheticSgrTap(clientX, clientY) { + if (!this.activeSessionId || !this.terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return; + const screen = this.terminal.element?.querySelector('.xterm-screen'); + const cell = this.terminal._core?._renderService?.dimensions?.css?.cell; + if (!screen || !cell?.width || !cell?.height) return; + const rect = screen.getBoundingClientRect(); + const col = Math.max(1, Math.min(this.terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1)); + const row = Math.max(1, Math.min(this.terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1)); + this._sendInputAsync(this.activeSessionId, `\x1b[<0;${col};${row}M\x1b[<0;${col};${row}m`); + }, + _installMobileTapMouseGuard() { const el = this.terminal?.element; if (!el || el._codemanTapMouseGuardInstalled) return; diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 04121595..c9edf859 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -94,6 +94,56 @@ describe('terminal touch tap mouse guard', () => { expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); }); + it('encodes a tap as an SGR press+release when the server strips mouse DECSETs (claude mode)', () => { + const { app } = loadTerminalUiHarness(); + const sent: Array<{ id: string; data: string }> = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); + app.terminal = { + cols: 80, + rows: 24, + element: { + querySelector: () => ({ getBoundingClientRect: () => ({ left: 10, top: 20 }) }), + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + + expect(app._sessionUsesServerMouseStrip()).toBe(true); + // touch at x=10+8*20+1, y=20+16*5+1 → col 21, row 6 (1-based) + app._sendSyntheticSgrTap(171, 101); + + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]); + }); + + it('clamps SGR tap coordinates to the terminal grid', () => { + const { app } = loadTerminalUiHarness(); + const sent: string[] = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputAsync = (_id: string, data: string) => sent.push(data); + app.terminal = { + cols: 80, + rows: 24, + element: { + querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }), + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + + app._sendSyntheticSgrTap(-50, 99999); + + expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']); + }); + + it('does not treat shell sessions as server-mouse-strip mode', () => { + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'shell' }]]); + + expect(app._sessionUsesServerMouseStrip()).toBe(false); + }); + it('allows trusted mouse events after the tap window expires', () => { const { app, setNow } = loadTerminalUiHarness(); const { element, dispatch } = createElementHarness(); From 7fb58648ba90e512c9e22c5110b04ed4c52351f0 Mon Sep 17 00:00:00 2001 From: Teigen Date: Tue, 7 Jul 2026 17:45:57 +0800 Subject: [PATCH 2/2] feat(web): forward wheel + guard clicks for desktop stripped-mouse sessions Desktop click-to-position-cursor died under the server's mouse-DECSET strip (same root cause as the mobile touchend tap regression): xterm's native mouse encoder only emits SGR while mouseTrackingMode is ON, but the server strips the enabling DECSETs from claude/codex/gemini output. Hand-encode the report for plain left-clicks (_handleDesktopTerminalClick), skipping every click that already means something else (synthetic/compat, modified, double/triple, drag-selection, off-grid, xterm encoder live). Also widen forwarding to the wheel: Claude Code 2.1.187+ scrolls its own transcript on SGR wheel reports and no longer captures wheel as select-menu navigation (verified against 2.1.202), so forward the wheel to the TUI for strip-mode sessions at the buffer bottom (40ms-coalesced to avoid a tmux send-keys storm). Shift+wheel and any scrolled-up viewport stay on xterm's local scrollback. Guard synthetic taps/clicks on viewport-at-bottom so a scrolled-up report can't hit-test the wrong row. Tests: 12 cases in test/terminal-touch-tap.test.ts. Verified E2E via Playwright against the live instance (wheel up/down forward, Shift+wheel local, click). --- src/web/public/terminal-ui.js | 118 ++++++++++++++++++-- test/terminal-touch-tap.test.ts | 183 ++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 10 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 326b818d..af03098d 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -323,13 +323,25 @@ Object.assign(CodemanApp.prototype, { // Register link provider for clickable file paths in Bash tool output this.registerFilePathLinkProvider(); - // Always use mouse wheel for terminal scrollback, never forward to application. - // Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation. + // Mouse wheel: forward to the TUI for strip-mode sessions, local scrollback + // otherwise. Claude Code (2.1.187+) scrolls its own transcript on SGR wheel + // reports — scrolled-away tool blocks re-render live and stay clickable — + // and its select menus no longer capture wheel as option navigation + // (verified against 2.1.202: /model menu highlight ignores wheel reports), + // so the original reason to keep the wheel local is gone for claude mode. + // Shift+wheel always scrolls xterm's local scrollback (Codeman's restored + // history lives there), and once the viewport left the bottom the wheel + // stays local until the user scrolls back down — so both scrollbacks stay + // reachable without a mode switch. container.addEventListener( 'wheel', (ev) => { ev.preventDefault(); const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1); + if (this._shouldForwardWheelToApp(ev)) { + this._sendSyntheticSgrWheel(ev.clientX, ev.clientY, lines); + return; + } this._noteTerminalUserScroll(lines); this.terminal.scrollLines(lines); }, @@ -487,6 +499,16 @@ Object.assign(CodemanApp.prototype, { ); } + // ── Desktop click-to-position cursor ────────────────────────────── + // A real mouse click normally reaches the PTY through xterm's own mouse + // encoder, but that encoder only runs while mouseTrackingMode is ON — and + // the server strips the enabling DECSETs from claude/codex/gemini output + // (isAltScreenStripMode, session.ts) so the wheel keeps scrolling + // scrollback. Desktop clicks therefore stopped reporting entirely (the + // same breakage the mobile touchend tap branch above works around). + // Hand-encode the SGR report for plain left-clicks on those sessions. + container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev)); + // Welcome message this.showWelcome(); @@ -2091,19 +2113,95 @@ Object.assign(CodemanApp.prototype, { return mode === 'claude' || mode === 'codex' || mode === 'gemini'; }, - // Encode a tap as an SGR mouse report (press + release at button 0) and send it - // to the PTY directly, bypassing xterm's mouse encoder. Column/row are derived - // from the touch point the same way xterm maps a click: offset inside - // .xterm-screen divided by the rendered cell size, 1-based, clamped to the grid. - _sendSyntheticSgrTap(clientX, clientY) { - if (!this.activeSessionId || !this.terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return; + // True when xterm's viewport shows the live PTY screen (not scrolled up into + // local scrollback). SGR coordinates are only meaningful then: the TUI's + // screen is the bottom `rows` of the buffer, so a report computed from a + // scrolled-up viewport would hit-test a completely different row. + _terminalViewportAtBottom() { + const buf = this.terminal?.buffer?.active; + return !buf || buf.viewportY >= buf.baseY; + }, + + // Map a viewport point to a 1-based terminal cell the same way xterm maps a + // click: offset inside .xterm-screen divided by the rendered cell size, + // clamped to the grid. Returns null when the terminal isn't measurable yet. + _clientPointToCell(clientX, clientY) { + if (!this.terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null; const screen = this.terminal.element?.querySelector('.xterm-screen'); const cell = this.terminal._core?._renderService?.dimensions?.css?.cell; - if (!screen || !cell?.width || !cell?.height) return; + if (!screen || !cell?.width || !cell?.height) return null; const rect = screen.getBoundingClientRect(); const col = Math.max(1, Math.min(this.terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1)); const row = Math.max(1, Math.min(this.terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1)); - this._sendInputAsync(this.activeSessionId, `\x1b[<0;${col};${row}M\x1b[<0;${col};${row}m`); + return { col, row }; + }, + + // Encode a tap as an SGR mouse report (press + release at button 0) and send it + // to the PTY directly, bypassing xterm's mouse encoder. + _sendSyntheticSgrTap(clientX, clientY) { + if (!this.activeSessionId) return; + if (!this._terminalViewportAtBottom()) return; // scrollback click → misfire, do nothing + const pos = this._clientPointToCell(clientX, clientY); + if (!pos) return; + this._sendInputAsync(this.activeSessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`); + }, + + // Wheel forwarding gate for the container wheel handler: strip-mode session, + // no Shift override, xterm's own encoder dormant, viewport at the bottom. + _shouldForwardWheelToApp(ev) { + if (ev.shiftKey) return false; + const mode = this.terminal?.modes?.mouseTrackingMode; + if (mode && mode !== 'none') return false; + if (!this._sessionUsesServerMouseStrip()) return false; + return this._terminalViewportAtBottom(); + }, + + // Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer + // cell. Reports are coalesced into one PTY write per ~40ms: a trackpad emits + // dozens of wheel events per second and each _sendInputAsync becomes a tmux + // send-keys on the server — unbatched, a single flick would spawn a process + // storm. Per-event tick count is capped (Claude applies its own scroll-speed + // multiplier and acceleration on top), and the queue is bounded so a wild + // scroll can't build a backlog that keeps scrolling after the finger stops. + _sendSyntheticSgrWheel(clientX, clientY, lines) { + if (!this.activeSessionId || !lines) return; + const pos = this._clientPointToCell(clientX, clientY); + if (!pos) return; + const btn = lines < 0 ? 64 : 65; + const ticks = Math.min(Math.abs(lines), 5); + const queued = this._wheelSgrQueue || ''; + if (queued.length > 512) return; + this._wheelSgrQueue = queued + `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks); + if (this._wheelSgrFlushTimer) return; + this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40); + }, + + _flushWheelSgrQueue() { + this._wheelSgrFlushTimer = null; + const data = this._wheelSgrQueue; + this._wheelSgrQueue = ''; + if (data && this.activeSessionId) this._sendInputAsync(this.activeSessionId, data); + }, + + // Desktop counterpart of the touchend tap branch: hand-encode an SGR report + // for a plain left-click when the server strips mouse DECSETs (see + // _sessionUsesServerMouseStrip). Every skip below is a click that already has + // a meaning elsewhere: synthetic/compat clicks after a touch tap (touchend + // reported already), modified clicks (shift keeps xterm's selection + // override), double/triple clicks (word/line selection), drag-selections, + // clicks outside the cell grid, and sessions where xterm's own encoder is + // live (it reported the click itself — a second report would double-move). + _handleDesktopTerminalClick(ev) { + if (!this.terminal || !ev?.isTrusted) return; + if (ev.button !== 0 || ev.detail !== 1) return; + if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return; + const mode = this.terminal.modes?.mouseTrackingMode; + if (mode && mode !== 'none') return; + if (!this._sessionUsesServerMouseStrip()) return; + if (this.terminal.hasSelection?.()) return; + if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return; + if (!ev.target?.closest?.('.xterm-screen')) return; + this._sendSyntheticSgrTap(ev.clientX, ev.clientY); }, _installMobileTapMouseGuard() { diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index c9edf859..4ab9b788 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -144,6 +144,189 @@ describe('terminal touch tap mouse guard', () => { expect(app._sessionUsesServerMouseStrip()).toBe(false); }); + it('desktop click: encodes SGR press+release for a plain left-click in strip mode', () => { + const { app } = loadTerminalUiHarness(); + const sent: Array<{ id: string; data: string }> = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); + app.terminal = { + cols: 80, + rows: 24, + modes: { mouseTrackingMode: 'none' }, + hasSelection: () => false, + element: { + querySelector: () => ({ getBoundingClientRect: () => ({ left: 10, top: 20 }) }), + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + + app._handleDesktopTerminalClick({ + isTrusted: true, + button: 0, + detail: 1, + clientX: 171, + clientY: 101, + target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) }, + }); + + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]); + }); + + it('desktop click: skips clicks that already have a meaning elsewhere', () => { + const { app } = loadTerminalUiHarness(); + const sent: string[] = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputAsync = (_id: string, data: string) => sent.push(data); + const terminal = () => ({ + cols: 80, + rows: 24, + modes: { mouseTrackingMode: 'none' } as { mouseTrackingMode: string }, + hasSelection: () => false, + element: { + querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }), + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }); + const click = (overrides: Record = {}) => ({ + isTrusted: true, + button: 0, + detail: 1, + clientX: 50, + clientY: 50, + target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) }, + ...overrides, + }); + + app.terminal = terminal(); + app._handleDesktopTerminalClick(click({ isTrusted: false })); // synthetic + app._handleDesktopTerminalClick(click({ button: 1 })); // middle button + app._handleDesktopTerminalClick(click({ detail: 2 })); // double-click word select + app._handleDesktopTerminalClick(click({ shiftKey: true })); // selection override + app._handleDesktopTerminalClick(click({ target: { closest: () => null } })); // outside grid + + app.terminal = { ...terminal(), hasSelection: () => true }; // drag-selection just ended + app._handleDesktopTerminalClick(click()); + + app.terminal = { ...terminal(), modes: { mouseTrackingMode: 'vt200' } }; // xterm encoder live + app._handleDesktopTerminalClick(click()); + + app.terminal = terminal(); + app.sessions = new Map([['sess-1', { mode: 'shell' }]]); // not a strip mode + app._handleDesktopTerminalClick(click()); + + expect(sent).toEqual([]); + }); + + it('desktop click: skips the compat click that follows a touch tap', () => { + const { app, setNow } = loadTerminalUiHarness(); + const sent: string[] = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputAsync = (_id: string, data: string) => sent.push(data); + app.terminal = { + cols: 80, + rows: 24, + modes: { mouseTrackingMode: 'none' }, + hasSelection: () => false, + element: { + querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }), + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + const click = { + isTrusted: true, + button: 0, + detail: 1, + clientX: 50, + clientY: 50, + target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) }, + }; + + app._suppressTrustedTapMouseEvents(); // touchend just handled the tap + app._handleDesktopTerminalClick(click); + expect(sent).toEqual([]); + + setNow(10_000); // window expired — a genuine mouse click reports again + app._handleDesktopTerminalClick(click); + expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']); + }); + + it('tap: does nothing while the viewport is scrolled up into local scrollback', () => { + const { app } = loadTerminalUiHarness(); + const sent: string[] = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputAsync = (_id: string, data: string) => sent.push(data); + app.terminal = { + cols: 80, + rows: 24, + buffer: { active: { viewportY: 10, baseY: 50 } }, // scrolled up + element: { + querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }), + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + + app._sendSyntheticSgrTap(50, 50); + expect(sent).toEqual([]); + + app.terminal.buffer.active.viewportY = 50; // back at the bottom + app._sendSyntheticSgrTap(50, 50); + expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']); + }); + + it('wheel: forwards to the app only for strip-mode sessions at the buffer bottom without Shift', () => { + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.terminal = { + modes: { mouseTrackingMode: 'none' }, + buffer: { active: { viewportY: 50, baseY: 50 } }, + }; + + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); + expect(app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false); // Shift = local scrollback + + app.terminal.buffer.active.viewportY = 10; // browsing local scrollback + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + app.terminal.buffer.active.viewportY = 50; + + app.terminal.modes.mouseTrackingMode = 'vt200'; // xterm's own encoder live + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + app.terminal.modes.mouseTrackingMode = 'none'; + + app.sessions = new Map([['sess-1', { mode: 'shell' }]]); // not a strip mode + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + }); + + it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => { + const { app } = loadTerminalUiHarness(); + const sent: Array<{ id: string; data: string }> = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); + app.terminal = { + cols: 80, + rows: 24, + element: { + querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }), + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + + app._sendSyntheticSgrWheel(50, 50, -2); // 2 ticks up + app._sendSyntheticSgrWheel(50, 50, 9); // capped at 5 ticks down + expect(sent).toEqual([]); // nothing until the flush timer fires + + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) + '\x1b[<65;7;4M'.repeat(5) }]); + + app._flushWheelSgrQueue(); // queue drained — no duplicate send + expect(sent).toHaveLength(1); + }); + it('allows trusted mouse events after the tap window expires', () => { const { app, setNow } = loadTerminalUiHarness(); const { element, dispatch } = createElementHarness();