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 <innocarpe@gmail.com>
Co-authored-by: Neil <nwparker@users.noreply.github.com>

* 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 <innocarpe@gmail.com>
Co-authored-by: Neil <nwparker@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-28 18:53:58 +00:00
committed by GitHub
co-authored by Wooseong Kim Neil Kelvin Amoaba
parent 6caa44eefb
commit 92a754a088
20 changed files with 494 additions and 28 deletions
@@ -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<typeof TerminalPaneView>[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<typeof create>
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())
})
+18 -2
View File
@@ -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)}
@@ -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) {
@@ -95,6 +95,7 @@ export type TerminalDocumentState = {
sel: TerminalDocumentSelection | null
longPressTimer: ReturnType<typeof setTimeout> | 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,
@@ -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') {
@@ -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<string, unknown>) {
if (!scope.interactionActive && (msg.type === 'terminal-input' || msg.type === 'terminal-tap')) {
return
}
scope.postToHost(msg)
}
@@ -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)
})
@@ -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) {
@@ -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)
})
})
@@ -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
}
}
@@ -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)
}
}
+2 -2
View File
@@ -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) {
+4 -5
View File
@@ -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) {
@@ -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'))
@@ -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<typeof useTerminalWebViewController> | undefined
function Probe({ active }: { active: boolean }) {
controller = useTerminalWebViewController({ interactionActive: active }, transport)
return null
}
act(() => {
root = create(<Probe active={false} />)
})
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(<Probe active={true} />))
expect(post.mock.lastCall?.[0]).toMatchObject({ type: 'set-interaction-active', active: true })
act(() => controller?.resetReadiness())
act(() => root?.update(<Probe active={false} />))
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<typeof useTerminalWebViewController> | 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(<Probe />)
})
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()
})
@@ -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
@@ -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<TerminalWebViewHandle>()
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 })
})
})
@@ -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<TerminalWebViewTransport['post']>()
const onTerminalInput = vi.fn()
const exposed: { current: ReturnType<typeof useTerminalWebViewController> | 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()
@@ -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 }
| {
@@ -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<string, unknown>) => {
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,