From 6f38e29e6d6fbb375397c9dd96cb930b2398126b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:39:41 -0700 Subject: [PATCH] fix(mobile): end mouse gestures whose pointerup never reached the surface Capture the mouse pointer on pointerdown so a drag that leaves the surface keeps delivering pointermove/pointerup; when capture is unavailable and the release is lost anyway, synthesize the release from the next buttons==0 pointermove or the next pointerdown, so a tracking TUI is never left with the left button latched down. --- ...minal-webview-mouse-click-drag-injected.ts | 8 ++++++- .../terminal-webview-mouse-click-drag.test.ts | 22 ++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts index 7a5d0bc4f0f..48b68e254bb 100644 --- a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts +++ b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts @@ -128,7 +128,13 @@ export const TERMINAL_MOUSE_CLICK_DRAG_JS = ` targetSurface.addEventListener('pointermove', function(e) { var gesture = mouseGesture; if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return; - if (!term || (e.buttons & 1) === 0) return; + if (!term) return; + if ((e.buttons & 1) === 0) { + // Why: a pointerup lost outside the WebView (capture unavailable) must + // end the gesture here, or a tracked press stays latched at the TUI. + abandonMouseGesture(); + return; + } gesture.lastX = e.clientX; gesture.lastY = e.clientY; if (!gesture.moved) { diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag.test.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag.test.ts index 2df4bf24ce8..d666c4d677a 100644 --- a/mobile/src/terminal/terminal-webview-mouse-click-drag.test.ts +++ b/mobile/src/terminal/terminal-webview-mouse-click-drag.test.ts @@ -254,7 +254,7 @@ describe('terminal WebView external mouse click and drag', () => { expect(new Set(motionCols).size).toBe(motionCols.length) }) - it('does not report press or motion for an x10 click-only TUI drag', () => { + it('does not report motion or release for an x10 click-only TUI drag', () => { boot() activeTerminal().modes.mouseTrackingMode = 'x10' @@ -338,6 +338,26 @@ describe('terminal WebView external mouse click and drag', () => { expect(terminalInputBytes(postMessage)).toBe('') }) + it('releases a tracked drag when the button state shows the pointerup was lost', () => { + boot() + activeTerminal().modes.mouseTrackingMode = 'drag' + + dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 }) + // Pointer re-enters with the button already up — the pointerup never arrived. + dispatchPointer('pointermove', { x: 200, y: 60, button: 0, buttons: 0 }) + + const reports = terminalInputBytes(postMessage).match(DEFAULT_MOUSE_REPORT_RE) ?? [] + expect(reports[reports.length - 1]?.charCodeAt(3)).toBe(35) + + postMessage.mockClear() + dispatchPointer('pointermove', { x: 240, y: 60, button: 0, buttons: 1 }) + + // The lost pointerup ended the gesture; a later unrelated move must not + // keep emitting motion for it. + expect(terminalInputBytes(postMessage)).toBe('') + }) + it('releases a tracked drag when the pointer is cancelled mid-gesture', () => { boot() activeTerminal().modes.mouseTrackingMode = 'drag'