From 92a754a0887b074c976742c29ab2ed6b31487bf6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:53:58 -0700 Subject: [PATCH] fix(mobile): suspend hidden and background terminal gestures (#31468) * fix(mobile): suspend gestures in hidden and background terminals Adapt the retained-pane interaction lifecycle from community PR #30324. Restore policy across readiness/recovery and keep output/query replies flowing. Release cancelled gesture frames, discard touches interrupted by suspension, and restore the resting pinch transform on every gesture cancellation. Co-authored-by: Wooseong Kim Co-authored-by: Neil * test(mobile): stub route focus where the web mount renders terminal panes * test(mobile): send production-shaped file taps and the clipboard prop in activity tests * fix(mobile): keep inactive and interrupted terminal moves away from xterm --------- Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Co-authored-by: Wooseong Kim Co-authored-by: Neil --- .../TerminalPaneView.activity.test.tsx | 76 +++++++++ mobile/src/session/TerminalPaneView.tsx | 20 ++- .../document/document-frame-registry.ts | 8 + .../src/terminal/document/document-scope.ts | 3 + .../terminal/document/host-message-router.ts | 9 ++ mobile/src/terminal/document/host-notify.ts | 3 + .../long-press-selection-drag.test.ts | 23 ++- .../document/normal-buffer-smooth-scroll.ts | 4 +- .../surface-interaction-lifecycle.test.ts | 152 ++++++++++++++++++ .../document/surface-interaction-lifecycle.ts | 27 ++++ .../document/surface-touch-gestures.ts | 36 ++++- mobile/src/terminal/document/tap-dispatch.ts | 4 +- mobile/src/terminal/document/wheel-scroll.ts | 9 +- .../terminal-web-document-mount.test.ts | 4 + .../terminal-webview-activity.test.tsx | 87 ++++++++++ .../src/terminal/terminal-webview-contract.ts | 1 + .../terminal-webview-engine-error.test.ts | 16 +- ...rminal-webview-interactive-output.test.tsx | 20 ++- .../src/terminal/terminal-webview-messages.ts | 1 + .../use-terminal-webview-controller.ts | 19 ++- 20 files changed, 494 insertions(+), 28 deletions(-) create mode 100644 mobile/src/session/TerminalPaneView.activity.test.tsx create mode 100644 mobile/src/terminal/document/surface-interaction-lifecycle.test.ts create mode 100644 mobile/src/terminal/document/surface-interaction-lifecycle.ts create mode 100644 mobile/src/terminal/terminal-webview-activity.test.tsx diff --git a/mobile/src/session/TerminalPaneView.activity.test.tsx b/mobile/src/session/TerminalPaneView.activity.test.tsx new file mode 100644 index 00000000000..9acf81e500d --- /dev/null +++ b/mobile/src/session/TerminalPaneView.activity.test.tsx @@ -0,0 +1,76 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +const probe = vi.hoisted(() => ({ + changeApp: (_state: string) => {}, + leaveRoute: () => {} +})) +vi.mock('react-native', () => ({ + View: 'View', + StyleSheet: { create: (value: unknown) => value }, + AppState: { + currentState: 'active', + addEventListener: (_name: string, callback: (state: string) => void) => { + probe.changeApp = callback + return { remove: vi.fn() } + } + } +})) +vi.mock('expo-router', async () => { + const { useEffect } = await import('react') + return { + useFocusEffect: (effect: () => () => void) => + useEffect(() => { + const leave = effect() + probe.leaveRoute = leave + return leave + }, [effect]) + } +}) +vi.mock('../terminal/TerminalWebView', () => ({ TerminalWebView: 'TerminalWebView' })) +import { TerminalPaneView } from './TerminalPaneView' + +it('retains the terminal but disables interaction on tab hiding, app backgrounding and route blur', async () => { + const props: Parameters[0] = { + handle: 'terminal', + active: true, + keyboardLift: 0, + textScale: 1, + onRef: vi.fn(), + onWebReady: vi.fn(), + onSelectionMode: vi.fn(), + onSelectionCopy: vi.fn(), + onSelectionEvicted: vi.fn(), + onClipboardWrite: vi.fn(), + onModesChanged: vi.fn(), + onKeyboardAvoidanceMetrics: vi.fn(), + onHaptic: vi.fn(), + onTerminalInput: vi.fn(), + onTerminalQueryReply: vi.fn(), + onTerminalTap: vi.fn(), + onFileTap: vi.fn(), + onOpenUrl: vi.fn(), + onTextScaleChange: vi.fn(), + onCellBoxChange: vi.fn() + } + let renderer!: ReturnType + await act(async () => { + renderer = create(createElement(TerminalPaneView, props)) + }) + const terminal = () => renderer.root.find((node) => String(node.type) === 'TerminalWebView') + expect(terminal().props.interactionActive).toBe(true) + await act(async () => probe.changeApp('background')) + expect(terminal().props.interactionActive).toBe(false) + await act(async () => probe.changeApp('active')) + expect(terminal().props.interactionActive).toBe(true) + await act(async () => + renderer.update(createElement(TerminalPaneView, { ...props, active: false })) + ) + expect(terminal().props.interactionActive).toBe(false) + await act(async () => renderer.update(createElement(TerminalPaneView, props))) + expect(terminal().props.interactionActive).toBe(true) + await act(async () => probe.leaveRoute()) + expect(terminal().props.interactionActive).toBe(false) + expect(renderer.root.findAll((node) => String(node.type) === 'TerminalWebView')).toHaveLength(1) + await act(async () => renderer.unmount()) +}) diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx index 761013a95ef..6b47b7b2789 100644 --- a/mobile/src/session/TerminalPaneView.tsx +++ b/mobile/src/session/TerminalPaneView.tsx @@ -1,5 +1,6 @@ -import { useCallback, useState } from 'react' -import { StyleSheet, View } from 'react-native' +import { useCallback, useEffect, useState } from 'react' +import { AppState, StyleSheet, View } from 'react-native' +import { useFocusEffect } from 'expo-router' import type { ParsedFileLinkLocation } from '../../../src/shared/file-link-location' import { TerminalWebView } from '../terminal/TerminalWebView' import type { @@ -61,6 +62,20 @@ export function TerminalPaneView({ if (active && !visited) { setVisited(true) } + const [routeFocused, setRouteFocused] = useState(false) + useFocusEffect( + useCallback(() => { + setRouteFocused(true) + return () => setRouteFocused(false) + }, []) + ) + const [appActive, setAppActive] = useState(() => AppState.currentState === 'active') + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => + setAppActive(state === 'active') + ) + return () => subscription.remove() + }, []) const setRef = useCallback( (ref: TerminalWebViewHandle | null) => { onRef(handle, ref) @@ -89,6 +104,7 @@ export function TerminalPaneView({ terminalTheme={terminalTheme} textScale={textScale} shownAtMount={active} + interactionActive={active && appActive && routeFocused} onWebReady={() => onWebReady(handle)} onSelectionMode={(a) => onSelectionMode(handle, a)} onSelectionCopy={(t) => onSelectionCopy(handle, t)} diff --git a/mobile/src/terminal/document/document-frame-registry.ts b/mobile/src/terminal/document/document-frame-registry.ts index 0c76c699447..2585e3171f1 100644 --- a/mobile/src/terminal/document/document-frame-registry.ts +++ b/mobile/src/terminal/document/document-frame-registry.ts @@ -20,6 +20,14 @@ export function scheduleDocumentFrame( return id } +export function cancelDocumentFrame(scope: TerminalDocumentScope, id: number) { + cancelAnimationFrame(id) + const at = scope.scheduledFrames.indexOf(id) + if (at !== -1) { + scope.scheduledFrames.splice(at, 1) + } +} + export function cancelDocumentFrames(scope: TerminalDocumentScope) { scope.framesStopped = true for (const id of scope.scheduledFrames) { diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 24d39049c5f..0074c3fd931 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -95,6 +95,7 @@ export type TerminalDocumentState = { sel: TerminalDocumentSelection | null longPressTimer: ReturnType | null longPressOrigin: TerminalDocumentTouchOrigin | null + interactionActive: boolean tapCandidate: TerminalDocumentTapCandidate | null bottomRowBackdrop: string surface: HTMLElement | null @@ -219,6 +220,7 @@ function createTerminalDocumentState(): TerminalDocumentState { sel: null, longPressTimer: null, longPressOrigin: null, + interactionActive: true, tapCandidate: null, wheelAccumDeltaY: 0, bottomRowBackdrop: '', @@ -253,6 +255,7 @@ function createTerminalDocumentState(): TerminalDocumentState { scheduledFrames: [], framesStopped: false, touchGesture: { + active: false, lastX: 0, lastY: 0, lastTime: 0, diff --git a/mobile/src/terminal/document/host-message-router.ts b/mobile/src/terminal/document/host-message-router.ts index e3bc07b0013..e6ab29d016d 100644 --- a/mobile/src/terminal/document/host-message-router.ts +++ b/mobile/src/terminal/document/host-message-router.ts @@ -1,3 +1,4 @@ +import { suspendSurfaceTouchGestures } from './surface-interaction-lifecycle' import type { TerminalDocumentScope } from './document-scope' import { applyFitScale } from './fit-scale' import { notify } from './host-notify' @@ -14,6 +15,7 @@ import { applyTextScale } from './text-scaling' import { resetWriteQueue } from './write-queue' export type TerminalHostMessage = { + active?: boolean id?: number type?: string cols?: number @@ -114,6 +116,13 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage } } else if (msg.type === 'reset-zoom') { applyFitScale(scope, 'reset-zoom-msg') + } else if (msg.type === 'set-interaction-active') { + if (typeof msg.active === 'boolean') { + scope.interactionActive = msg.active + if (!msg.active) { + suspendSurfaceTouchGestures(scope) + } + } } else if (msg.type === 'set-theme') { applyTerminalTheme(scope, msg.terminalTheme) } else if (msg.type === 'cancel-select') { diff --git a/mobile/src/terminal/document/host-notify.ts b/mobile/src/terminal/document/host-notify.ts index 88d0f73a582..8c46eda659c 100644 --- a/mobile/src/terminal/document/host-notify.ts +++ b/mobile/src/terminal/document/host-notify.ts @@ -4,6 +4,9 @@ import type { TerminalEngineError } from './document-host-seams' export type { TerminalEngineError } from './document-host-seams' export function notify(scope: TerminalDocumentScope, msg: Record) { + if (!scope.interactionActive && (msg.type === 'terminal-input' || msg.type === 'terminal-tap')) { + return + } scope.postToHost(msg) } diff --git a/mobile/src/terminal/document/long-press-selection-drag.test.ts b/mobile/src/terminal/document/long-press-selection-drag.test.ts index b7605698b75..920fee615f0 100644 --- a/mobile/src/terminal/document/long-press-selection-drag.test.ts +++ b/mobile/src/terminal/document/long-press-selection-drag.test.ts @@ -99,7 +99,13 @@ async function startTuiDocument() { surface.dispatchEvent(event) } const ofType = (type: string) => posted.filter((message) => message.type === type) - return { touch, ofType, selections, clears: () => clears } + return { + touch, + ofType, + selections, + clears: () => clears, + setActive: (active: boolean) => started.send({ type: 'set-interaction-active', active }) + } } describe('long press then drag on the terminal surface', () => { @@ -147,3 +153,18 @@ describe('long press then drag on the terminal surface', () => { expect(doc.selections).toEqual([]) }) }) + +it('requires a new touch after a hide/show interruption before scrolling resumes', async () => { + const doc = await startTuiDocument() + doc.touch('touchstart', cellPoint(7, ROW)) + doc.setActive(false) + doc.setActive(true) + doc.touch('touchmove', cellPoint(7, ROW + 3)) + doc.touch('touchend') + vi.advanceTimersByTime(600) + expect(doc.ofType('terminal-input')).toEqual([]) + expect(doc.ofType('set-select-mode')).toEqual([]) + doc.touch('touchstart', cellPoint(7, ROW)) + doc.touch('touchmove', cellPoint(7, ROW + 3)) + expect(doc.ofType('terminal-input').length).toBeGreaterThan(0) +}) diff --git a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts index 3d791f7d862..95b8853d7bc 100644 --- a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts +++ b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts @@ -1,7 +1,7 @@ import { getCellHeight, getTotalScale } from './cell-metrics' import { updateScrollIndicator } from './viewport-transform' import type { TerminalDocumentScope } from './document-scope' -import { scheduleDocumentFrame } from './document-frame-registry' +import { cancelDocumentFrame, scheduleDocumentFrame } from './document-frame-registry' export function clampNormalScrollLines(scope: TerminalDocumentScope, lines: number) { if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || lines === 0) { @@ -91,7 +91,7 @@ export function queuedNormalBufferScrollReachesRow(scope: TerminalDocumentScope) export function resetSmoothScrollOffset(scope: TerminalDocumentScope) { scope.pendingNormalScrollDeltaY = 0 if (scope.normalScrollFrameId !== null) { - cancelAnimationFrame(scope.normalScrollFrameId) + cancelDocumentFrame(scope, scope.normalScrollFrameId) scope.normalScrollFrameId = null } if (scope.smoothScrollOffsetY === 0) { diff --git a/mobile/src/terminal/document/surface-interaction-lifecycle.test.ts b/mobile/src/terminal/document/surface-interaction-lifecycle.test.ts new file mode 100644 index 00000000000..99240289965 --- /dev/null +++ b/mobile/src/terminal/document/surface-interaction-lifecycle.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTerminalDocumentScope } from './document-scope' +import { handleMsg } from './host-message-router' +import { notify } from './host-notify' +import { dispatcherShouldBlockSurface } from './tap-dispatch' +import { scheduleDocumentFrame } from './document-frame-registry' +import { attachSurfaceEventHandlers } from './surface-touch-gestures' +import { terminalDocumentDouble } from './document-terminal-double.test-support' + +afterEach(() => vi.restoreAllMocks()) + +describe('inactive terminal interaction ownership', () => { + it.each(['suspension', 'touchcancel'] as const)( + 'restores the resting transform when %s interrupts a pinch', + (interruption) => { + const postToHost = vi.fn() + const scope = createTerminalDocumentScope({ postToHost }) + const surface = document.createElement('div') + document.body.appendChild(surface) + scope.surface = surface + scope.term = terminalDocumentDouble().terminal + attachSurfaceEventHandlers(scope, surface) + const touch = (type: string, xs: number[]) => { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'touches', { + value: xs.map((clientX, identifier) => ({ + identifier, + clientX, + clientY: 10, + target: surface + })) + }) + surface.dispatchEvent(event) + } + + touch('touchstart', [10, 30]) + touch('touchmove', [10, 40]) + expect(surface.style.transform).toContain('scale(1.5)') + + if (interruption === 'suspension') { + handleMsg(scope, { type: 'set-interaction-active', active: false }) + handleMsg(scope, { type: 'set-interaction-active', active: true }) + } else { + touch('touchcancel', []) + } + touch('touchend', []) + + expect(surface.style.transform).toBe('translate(0px,0px) scale(1)') + expect(scope.term.options.fontSize).toBe(13) + expect(postToHost.mock.calls.some(([message]) => message.type === 'font-scale-changed')).toBe( + false + ) + touch('touchstart', [10, 30]) + touch('touchmove', [10, 40]) + expect(surface.style.transform).toContain('scale(1.5)') + surface.remove() + } + ) + + it('keeps interrupted and inactive moves away from xterm', () => { + const scope = createTerminalDocumentScope() + const surface = document.createElement('div') + document.body.appendChild(surface) + scope.surface = surface + scope.term = terminalDocumentDouble().terminal + attachSurfaceEventHandlers(scope, surface) + const reachedDocument: string[] = [] + const onDocument = (e: Event) => reachedDocument.push(e.type) + document.addEventListener('touchmove', onDocument) + document.addEventListener('wheel', onDocument) + const move = () => { + const event = new Event('touchmove', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'touches', { + value: [{ identifier: 0, clientX: 10, clientY: 10, target: surface }] + }) + surface.dispatchEvent(event) + } + + handleMsg(scope, { type: 'set-interaction-active', active: false }) + move() + surface.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 40 })) + handleMsg(scope, { type: 'set-interaction-active', active: true }) + move() + + expect(reachedDocument).toEqual([]) + document.removeEventListener('touchmove', onDocument) + document.removeEventListener('wheel', onDocument) + surface.remove() + }) + + it('releases cancelled gesture frames across repeated hide/show cycles', () => { + const scope = createTerminalDocumentScope() + let nextFrame = 0 + vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => ++nextFrame) + const cancel = vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {}) + const paint = vi.fn() + const paintFrame = scheduleDocumentFrame(scope, paint) + for (let cycle = 0; cycle < 1000; cycle++) { + scope.touchGesture.momentumId = scheduleDocumentFrame(scope, () => {}) + scope.normalScrollFrameId = scheduleDocumentFrame(scope, () => {}) + handleMsg(scope, { type: 'set-interaction-active', active: false }) + handleMsg(scope, { type: 'set-interaction-active', active: true }) + } + expect(scope.scheduledFrames).toEqual([paintFrame]) + expect(cancel).toHaveBeenCalledTimes(2000) + expect(paint).not.toHaveBeenCalled() + expect(scope.framesStopped).toBe(false) + }) + + it('cancels pending motion when hidden and admits a new gesture when shown again', () => { + const scope = createTerminalDocumentScope() + const cancel = vi.spyOn(globalThis, 'cancelAnimationFrame') + scope.touchGesture.momentumId = 10 + scope.normalScrollFrameId = 11 + scope.pendingNormalScrollDeltaY = 42 + scope.touchGesture.velY = 2 + scope.touchGesture.accumDelta = 9 + handleMsg(scope, { type: 'set-interaction-active', active: false }) + expect(cancel.mock.calls.flat()).toEqual(expect.arrayContaining([10, 11])) + expect(scope.touchGesture.momentumId).toBeNull() + expect(scope.pendingNormalScrollDeltaY).toBe(0) + expect(scope.touchGesture.velY).toBe(0) + expect(dispatcherShouldBlockSurface(scope)).toBe(true) + handleMsg(scope, { type: 'set-interaction-active', active: true }) + expect(dispatcherShouldBlockSurface(scope)).toBe(false) + }) + + it('drops delayed gesture input while retaining terminal query replies and readiness', () => { + const postToHost = vi.fn() + const scope = createTerminalDocumentScope({ postToHost }) + handleMsg(scope, { type: 'set-interaction-active', active: false }) + notify(scope, { type: 'terminal-input', bytes: '\u001b[A' }) + notify(scope, { type: 'terminal-tap' }) + notify(scope, { type: 'terminal-data', bytes: '\u001b[1;1R' }) + notify(scope, { type: 'web-ready' }) + expect(postToHost.mock.calls.map(([message]) => message.type)).toEqual([ + 'terminal-data', + 'web-ready' + ]) + handleMsg(scope, { type: 'set-interaction-active', active: true }) + notify(scope, { type: 'terminal-input', bytes: '\u001b[A' }) + expect(postToHost).toHaveBeenLastCalledWith({ type: 'terminal-input', bytes: '\u001b[A' }) + }) + + it('does not interpret an invalid activity value as a request to enable input', () => { + const scope = createTerminalDocumentScope() + handleMsg(scope, { type: 'set-interaction-active', active: false }) + handleMsg(scope, { type: 'set-interaction-active' }) + expect(scope.interactionActive).toBe(false) + }) +}) diff --git a/mobile/src/terminal/document/surface-interaction-lifecycle.ts b/mobile/src/terminal/document/surface-interaction-lifecycle.ts new file mode 100644 index 00000000000..3a3d5bdee9a --- /dev/null +++ b/mobile/src/terminal/document/surface-interaction-lifecycle.ts @@ -0,0 +1,27 @@ +import type { TerminalDocumentScope } from './document-scope' +import { stopSurfaceTouchGestures } from './surface-touch-gestures' +import { resetSmoothScrollOffset } from './normal-buffer-smooth-scroll' +import { clearLongPress } from './tap-dispatch' +import { stopEdgeScroll } from './selection-overlay' + +export function suspendSurfaceTouchGestures(scope: TerminalDocumentScope) { + stopSurfaceTouchGestures(scope) + resetSmoothScrollOffset(scope) + clearLongPress(scope) + stopEdgeScroll(scope) + scope.tapCandidate = null + scope.mouseGesture = null + scope.wheelAccumDeltaY = 0 + scope.touchGesture.velY = 0 + scope.touchGesture.accumDelta = 0 + scope.touchGesture.isPinching = false + scope.touchDispatch = { + mode: 'idle', + touchId: null, + touchIds: null, + longPressFingerInsideOverlay: false + } + if (scope.sel) { + scope.sel.activeHandle = null + } +} diff --git a/mobile/src/terminal/document/surface-touch-gestures.ts b/mobile/src/terminal/document/surface-touch-gestures.ts index 21096a1b027..7b76ae8ff8e 100644 --- a/mobile/src/terminal/document/surface-touch-gestures.ts +++ b/mobile/src/terminal/document/surface-touch-gestures.ts @@ -1,9 +1,10 @@ import { getCellHeight, getTotalScale } from './cell-metrics' import type { TerminalDocumentScope } from './document-scope' -import { scheduleDocumentFrame } from './document-frame-registry' +import { cancelDocumentFrame, scheduleDocumentFrame } from './document-frame-registry' import { touchesInRoot } from './document-host-seams' import { clampPan } from './fit-scale' import { notify } from './host-notify' +import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag' import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding' import { @@ -27,6 +28,7 @@ const RELEASE_VELOCITY_MAX_AGE_MS = 100 type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean } export type TerminalTouchState = { + active: boolean lastX: number lastY: number lastTime: number @@ -101,7 +103,7 @@ export function attachSurfaceEventHandlers( return } if (scope.touchGesture.momentumId) { - cancelAnimationFrame(scope.touchGesture.momentumId) + cancelDocumentFrame(scope, scope.touchGesture.momentumId) scope.touchGesture.momentumId = null // A touch that stopped momentum must not also fire a tap. scope.tapCandidate = null @@ -125,6 +127,7 @@ export function attachSurfaceEventHandlers( scope.touchGesture.velY = 0 scope.touchGesture.accumDelta = 0 } + scope.touchGesture.active = touches.length > 0 }, { capture: true, passive: true } ) @@ -132,14 +135,19 @@ export function attachSurfaceEventHandlers( targetSurface.addEventListener( 'touchmove', function (e) { - if (targetSurface !== scope.surface || dispatcherShouldBlockSurface(scope)) { - return - } - if (!scope.term) { + if ( + targetSurface !== scope.surface || + scope.touchDispatch.mode === 'select-drag' || + !scope.term + ) { return } + // Claim inactive and interrupted moves too, or xterm's own touch scroller moves the buffer. e.preventDefault() e.stopPropagation() + if (!scope.interactionActive || !scope.touchGesture.active) { + return + } const touches = touchesInRoot(scope.root, e.touches) if (touches.length === 2) { @@ -210,7 +218,7 @@ export function attachSurfaceEventHandlers( if (targetSurface !== scope.surface || dispatcherShouldBlockSurface(scope)) { return } - if (!scope.term) { + if (!scope.term || !scope.touchGesture.active) { return } @@ -240,6 +248,7 @@ export function attachSurfaceEventHandlers( } if (touches.length === 0) { + scope.touchGesture.active = false // A finger resting after its last move has no remaining momentum to launch. const rested = Date.now() - scope.touchGesture.lastTime > RELEASE_VELOCITY_MAX_AGE_MS let vel = rested ? 0 : scope.touchGesture.velY @@ -298,11 +307,22 @@ export function startSurfaceTouchGestures(scope: TerminalDocumentScope) { } export function stopSurfaceTouchGestures(scope: TerminalDocumentScope) { + const interruptedPinch = scope.touchGesture.isPinching + scope.touchGesture.active = false if (scope.touchGesture.momentumId !== null) { - cancelAnimationFrame(scope.touchGesture.momentumId) + cancelDocumentFrame(scope, scope.touchGesture.momentumId) scope.touchGesture.momentumId = null } scope.touchGesture.velY = 0 scope.touchGesture.accumDelta = 0 scope.touchGesture.isPinching = false + if (interruptedPinch) { + scope.userScale = 1 + scope.panX = 0 + scope.panY = 0 + if (scope.surface) { + updateTransform(scope) + } + emitKeyboardAvoidanceMetrics(scope) + } } diff --git a/mobile/src/terminal/document/tap-dispatch.ts b/mobile/src/terminal/document/tap-dispatch.ts index 630f2c39b16..10b479d60fe 100644 --- a/mobile/src/terminal/document/tap-dispatch.ts +++ b/mobile/src/terminal/document/tap-dispatch.ts @@ -86,7 +86,7 @@ export function touchSlopExceeded(scope: TerminalDocumentScope, t: Touch) { // Suppress surface handlers while document dispatch owns a selection-handle drag. export function dispatcherShouldBlockSurface(scope: TerminalDocumentScope) { - return scope.touchDispatch.mode === 'select-drag' + return !scope.interactionActive || scope.touchDispatch.mode === 'select-drag' } // Use identical capture options for listener registration and removal. @@ -95,7 +95,7 @@ const CAPTURE_PASSIVE = { capture: true, passive: true } // Touch targets stay at their start element, so owned drags still belong here after crossing the host edge. function touchIsThisDocuments(scope: TerminalDocumentScope, e: { target: EventTarget | null }) { - return eventTargetInRoot(scope.root, e.target) + return scope.interactionActive && eventTargetInRoot(scope.root, e.target) } function onDocumentTouchStart(scope: TerminalDocumentScope, e: TouchEvent) { diff --git a/mobile/src/terminal/document/wheel-scroll.ts b/mobile/src/terminal/document/wheel-scroll.ts index 36f55469146..d71d3357488 100644 --- a/mobile/src/terminal/document/wheel-scroll.ts +++ b/mobile/src/terminal/document/wheel-scroll.ts @@ -1,6 +1,5 @@ import { getCellHeight, getTotalScale } from './cell-metrics' import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding' -import { dispatcherShouldBlockSurface } from './tap-dispatch' import { enqueueNormalBufferScrollDelta, resetSmoothScrollOffset @@ -29,15 +28,15 @@ export function attachSurfaceWheelHandler( targetSurface.addEventListener( 'wheel', function (e) { - if (dispatcherShouldBlockSurface(scope)) { - return - } - if (!scope.term) { + if (scope.touchDispatch.mode === 'select-drag' || !scope.term) { return } // Claim wheels before xterm can emit query-gated cursor keys or scroll its hidden viewport; use the touch router instead. e.preventDefault() e.stopPropagation() + if (!scope.interactionActive) { + return + } // Swallow ctrl+wheel trackpad pinch so it cannot send cursor keys to the TUI. if (e.ctrlKey) { diff --git a/mobile/src/terminal/terminal-web-document-mount.test.ts b/mobile/src/terminal/terminal-web-document-mount.test.ts index 93074911274..8aca077a726 100644 --- a/mobile/src/terminal/terminal-web-document-mount.test.ts +++ b/mobile/src/terminal/terminal-web-document-mount.test.ts @@ -39,6 +39,10 @@ vi.mock('react-native', () => ({ StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 }, AppState: { addEventListener: () => ({ remove() {} }) } })) +vi.mock('expo-router', async () => { + const { useEffect } = await import('react') + return { useFocusEffect: (effect: () => () => void) => useEffect(effect, [effect]) } +}) vi.mock('lucide-react-native', () => ({ RefreshCw: 'Icon' })) vi.mock('./TerminalWebView', () => import('./TerminalWebView.web')) diff --git a/mobile/src/terminal/terminal-webview-activity.test.tsx b/mobile/src/terminal/terminal-webview-activity.test.tsx new file mode 100644 index 00000000000..c0606dd2ac2 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-activity.test.tsx @@ -0,0 +1,87 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { useTerminalWebViewController } from './use-terminal-webview-controller' + +vi.mock('react-native', () => ({ AppState: { currentState: 'active' } })) +vi.mock('./terminal-webview-engine-error-state', () => ({ + useTerminalWebViewEngineErrorState: () => ({ + clearEngineError: vi.fn(), + engineError: null, + reportEngineError: vi.fn(), + reportNativeEngineError: vi.fn() + }) +})) +let root: ReactTestRenderer | undefined + +afterEach(() => act(() => root?.unmount())) + +it('restores the current activity policy before queued commands on readiness and reload', () => { + const post = vi.fn() + const transport = { post, pingsOnForegroundRecovery: () => false } + let controller: ReturnType | undefined + function Probe({ active }: { active: boolean }) { + controller = useTerminalWebViewController({ interactionActive: active }, transport) + return null + } + act(() => { + root = create() + }) + if (!controller) { + throw new Error('Controller did not mount') + } + act(() => controller?.receive({ type: 'web-ready' })) + expect(post.mock.calls[0][0]).toMatchObject({ type: 'set-interaction-active', active: false }) + act(() => root?.update()) + expect(post.mock.lastCall?.[0]).toMatchObject({ type: 'set-interaction-active', active: true }) + act(() => controller?.resetReadiness()) + act(() => root?.update()) + post.mockClear() + act(() => controller?.receive({ type: 'web-ready' })) + expect(post.mock.calls[0][0]).toMatchObject({ type: 'set-interaction-active', active: false }) +}) + +it('rejects bridge-delayed gestures while keeping query replies and readiness available', () => { + const onTerminalInput = vi.fn(), + onTerminalTap = vi.fn(), + onFileTap = vi.fn(), + onOpenUrl = vi.fn() + const onTerminalQueryReply = vi.fn(), + onWebReady = vi.fn() + let controller: ReturnType | undefined + const transport = { post: vi.fn(), pingsOnForegroundRecovery: () => false } + function Probe() { + controller = useTerminalWebViewController( + { + interactionActive: false, + onTerminalInput, + onTerminalTap, + onFileTap, + onOpenUrl, + onTerminalQueryReply, + onWebReady + }, + transport + ) + return null + } + act(() => { + root = create() + }) + if (!controller) { + throw new Error('Controller did not mount') + } + controller.receive({ type: 'terminal-input', bytes: '\x1b[A' }) + controller.receive({ type: 'terminal-tap' }) + controller.receive({ + type: 'terminal-file-tap', + candidates: [{ pathText: '/tmp/file', line: null, column: null }] + }) + controller.receive({ type: 'open-url', url: 'https://example.com' }) + controller.receive({ type: 'terminal-data', bytes: '\x1b[0n' }) + act(() => controller?.receive({ type: 'web-ready' })) + for (const handler of [onTerminalInput, onTerminalTap, onFileTap, onOpenUrl]) { + expect(handler).not.toHaveBeenCalled() + } + expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[0n') + expect(onWebReady).toHaveBeenCalledOnce() +}) diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index 26a60ca5cf6..8c82f709fbe 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -93,6 +93,7 @@ export type TerminalWebViewProps = { textScale?: number // Only initially shown views build pre-ready engines, limiting WebGL context use. shownAtMount?: boolean + interactionActive?: boolean onWebReady?: () => void onEngineError?: (message: string) => void } & TerminalSelectionEvents diff --git a/mobile/src/terminal/terminal-webview-engine-error.test.ts b/mobile/src/terminal/terminal-webview-engine-error.test.ts index fa1b931f9a4..ca306a019c5 100644 --- a/mobile/src/terminal/terminal-webview-engine-error.test.ts +++ b/mobile/src/terminal/terminal-webview-engine-error.test.ts @@ -203,7 +203,13 @@ describe('TerminalWebView engine errors', () => { expect(postedCommands().map((command) => command.type)).toEqual(['ping']) postWebViewMessage(renderer, { type: 'pong', pingId: ping?.id }) - expect(postedCommands().map((command) => command.type)).toEqual(['ping', 'set-theme', 'write']) + expect(postedCommands().map((command) => command.type)).toEqual([ + 'ping', + 'set-interaction-active', + 'set-theme', + 'write' + ]) + expect(postedCommands()[1]).toMatchObject({ type: 'set-interaction-active', active: true }) expect(onWebReady).toHaveBeenCalledTimes(1) }) @@ -229,6 +235,7 @@ describe('TerminalWebView engine errors', () => { const terminalRef = createRef() const { onEngineError, renderer } = createTerminalWebViewRenderer(vi.fn(), { ref: terminalRef, + interactionActive: false, terminalTheme: { mode: 'light', theme: { background: '#ffffff', foreground: '#111111' } @@ -248,6 +255,11 @@ describe('TerminalWebView engine errors', () => { expect(onEngineError).not.toHaveBeenCalled() postWebViewMessage(renderer, { type: 'web-ready' }) - expect(postedCommands().map((command) => command.type)).toEqual(['set-theme', 'write']) + expect(postedCommands().map((command) => command.type)).toEqual([ + 'set-interaction-active', + 'set-theme', + 'write' + ]) + expect(postedCommands()[0]).toMatchObject({ type: 'set-interaction-active', active: false }) }) }) diff --git a/mobile/src/terminal/terminal-webview-interactive-output.test.tsx b/mobile/src/terminal/terminal-webview-interactive-output.test.tsx index 029f86f15b1..a84f902af16 100644 --- a/mobile/src/terminal/terminal-webview-interactive-output.test.tsx +++ b/mobile/src/terminal/terminal-webview-interactive-output.test.tsx @@ -18,7 +18,7 @@ vi.mock('./terminal-webview-engine-error-state', () => ({ let root: ReactTestRenderer | undefined -function mountController() { +function mountController(interactionActive = true) { const post = vi.fn() const onTerminalInput = vi.fn() const exposed: { current: ReturnType | null } = { @@ -26,7 +26,10 @@ function mountController() { } const transport = { post, pingsOnForegroundRecovery: () => false } function Probe() { - exposed.current = useTerminalWebViewController({ onTerminalInput }, transport) + exposed.current = useTerminalWebViewController( + { onTerminalInput, interactionActive }, + transport + ) return null } act(() => { @@ -77,6 +80,19 @@ describe('terminal scroll response batching', () => { expect(writes()).toEqual(['first', 'pending']) }) + it('rejects inactive gesture notifications without changing output batching', () => { + vi.useFakeTimers() + const { controller, onTerminalInput, writes } = mountController(false) + controller.handle.write('first') + controller.handle.write('pending') + controller.receive({ type: 'terminal-input', bytes: '\x1b[A' }) + vi.advanceTimersByTime(16) + expect(onTerminalInput).not.toHaveBeenCalled() + expect(writes()).toEqual(['first']) + vi.advanceTimersByTime(32) + expect(writes()).toEqual(['first', 'pending']) + }) + it('drops pending gesture output before a new document snapshot', () => { vi.useFakeTimers() const { controller, writes } = mountController() diff --git a/mobile/src/terminal/terminal-webview-messages.ts b/mobile/src/terminal/terminal-webview-messages.ts index fb961b23c22..2c6ad6031ee 100644 --- a/mobile/src/terminal/terminal-webview-messages.ts +++ b/mobile/src/terminal/terminal-webview-messages.ts @@ -4,6 +4,7 @@ import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link export type TerminalFrame = { width: number; height: number } export type TerminalWebViewCommand = + | { type: 'set-interaction-active'; active: boolean } | { type: 'ping'; id?: number } | { type: 'write'; id?: number; data: string } | { diff --git a/mobile/src/terminal/use-terminal-webview-controller.ts b/mobile/src/terminal/use-terminal-webview-controller.ts index 006d76b9e8a..e82e3fcf16c 100644 --- a/mobile/src/terminal/use-terminal-webview-controller.ts +++ b/mobile/src/terminal/use-terminal-webview-controller.ts @@ -31,6 +31,7 @@ export function useTerminalWebViewController( ) { const { terminalTheme, + interactionActive = true, textScale = 1, onWebReady, onEngineError, @@ -112,10 +113,12 @@ export function useTerminalWebViewController( onWebReady?.() } // Reload drops commands; readiness must restore the selected theme even if its React value did not change. + sendToDocument({ type: 'set-interaction-active', active: interactionActive }) sendToDocument({ type: 'set-theme', terminalTheme }) flushPendingMessages() }, [ + interactionActive, clearEngineError, clearWebReadyWatchdog, flushPendingMessages, @@ -125,10 +128,17 @@ export function useTerminalWebViewController( ] ) + useEffect(() => { + if (isWebReadyRef.current) { + sendToDocument({ type: 'set-interaction-active', active: interactionActive }) + } + }, [sendToDocument, interactionActive]) + const receive = useCallback( (msg: Record) => { routeTerminalQueryReply(msg, onTerminalQueryReply) if ( + interactionActive && msg.type === 'terminal-input' && typeof msg.bytes === 'string' && isTerminalGestureInput(msg.bytes) @@ -162,16 +172,17 @@ export function useTerminalWebViewController( onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, - onTerminalInput, - onTerminalTap, - onFileTap, - onOpenUrl, + onTerminalInput: interactionActive ? onTerminalInput : undefined, + onTerminalTap: interactionActive ? onTerminalTap : undefined, + onFileTap: interactionActive ? onFileTap : undefined, + onOpenUrl: interactionActive ? onOpenUrl : undefined, onTextScaleChange }) } }, [ confirmWebReady, + interactionActive, promises, reportEngineError, onSelectionMode,