diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index bc5f7e8e9..9ea0cb024 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -70,8 +70,10 @@ import { loadTerminalAccessoryLayout } from '../../../../src/terminal/terminal-accessory-layout' import { + clearTerminalLiveInputFocusTimer, getTerminalLiveSpecialKeyBytes, - isTerminalLiveInputWithinByteLimit + isTerminalLiveInputWithinByteLimit, + scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input' import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane' @@ -1048,6 +1050,7 @@ export default function SessionScreen() { const viewportMeasuredRef = useRef(false) const terminalRefs = useRef>(new Map()) const liveInputRef = useRef(null) + const liveInputFocusTimerRef = useRef | null>(null) const terminalUnsubsRef = useRef void>>(new Map()) const subscribingHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) @@ -1132,6 +1135,7 @@ export default function SessionScreen() { // so pending animation callbacks cannot clear a newer/unmounted surface. toastSeqRef.current += 1 clearToastHideTimer() + clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) } }, [clearToastHideTimer]) @@ -2714,8 +2718,9 @@ export default function SessionScreen() { }) setLiveInputCapture('') if (nextEnabled) { - setTimeout(() => liveInputRef.current?.focus(), 50) + scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) } else { + clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) liveInputRef.current?.blur() } }, [activeHandle, liveInputTerminalHandles]) diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index 0bad02da6..3b60155b8 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -1,11 +1,22 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_LIVE_INPUT_MAX_BYTES, + clearTerminalLiveInputFocusTimer, getTerminalLiveSpecialKeyBytes, - isTerminalLiveInputWithinByteLimit + isTerminalLiveInputWithinByteLimit, + scheduleTerminalLiveInputFocus, + type TerminalLiveInputFocusTimerRef } from './terminal-live-input' +function createTimerRef(): TerminalLiveInputFocusTimerRef { + return { current: null } +} + describe('terminal live input', () => { + afterEach(() => { + vi.useRealTimers() + }) + it('maps phone keyboard special keys to PTY bytes', () => { expect(getTerminalLiveSpecialKeyBytes('Backspace')).toBe('\x7f') expect(getTerminalLiveSpecialKeyBytes('Enter')).toBeNull() @@ -22,4 +33,32 @@ describe('terminal live input', () => { isTerminalLiveInputWithinByteLimit('é'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES / 2 + 1)) ).toBe(false) }) + + it('replaces pending deferred focus work', () => { + vi.useFakeTimers() + const timerRef = createTimerRef() + const staleFocus = vi.fn() + const nextFocus = vi.fn() + + scheduleTerminalLiveInputFocus(timerRef, staleFocus) + scheduleTerminalLiveInputFocus(timerRef, nextFocus) + vi.runOnlyPendingTimers() + + expect(staleFocus).not.toHaveBeenCalled() + expect(nextFocus).toHaveBeenCalledTimes(1) + expect(timerRef.current).toBeNull() + }) + + it('clears pending deferred focus work', () => { + vi.useFakeTimers() + const timerRef = createTimerRef() + const focus = vi.fn() + + scheduleTerminalLiveInputFocus(timerRef, focus) + clearTerminalLiveInputFocusTimer(timerRef) + vi.runOnlyPendingTimers() + + expect(focus).not.toHaveBeenCalled() + expect(timerRef.current).toBeNull() + }) }) diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index 87b6544fc..f3e15fea2 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -2,6 +2,10 @@ const TERMINAL_LIVE_INPUT_MAX_BYTES = 256 * 1024 const encoder = new TextEncoder() +export type TerminalLiveInputFocusTimerRef = { + current: ReturnType | null +} + export function getTerminalLiveSpecialKeyBytes(key: string): string | null { if (key === 'Backspace') { return '\x7f' @@ -16,4 +20,26 @@ export function isTerminalLiveInputWithinByteLimit( return encoder.encode(text).byteLength <= maxBytes } +export function clearTerminalLiveInputFocusTimer(timerRef: TerminalLiveInputFocusTimerRef): void { + if (timerRef.current === null) { + return + } + clearTimeout(timerRef.current) + timerRef.current = null +} + +export function scheduleTerminalLiveInputFocus( + timerRef: TerminalLiveInputFocusTimerRef, + focus: () => void, + delayMs = 50 +): void { + // Why: live input can be toggled during route changes; replacing the pending + // focus timer prevents stale native TextInput focus after unmount/disable. + clearTerminalLiveInputFocusTimer(timerRef) + timerRef.current = setTimeout(() => { + timerRef.current = null + focus() + }, delayMs) +} + export { TERMINAL_LIVE_INPUT_MAX_BYTES }