diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 994798712..2119c2da2 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -24,6 +24,7 @@ import { AlertTriangle, ArrowUp, Bot, + ChevronDown, ChevronLeft, ChevronRight, ChevronsRight, @@ -100,6 +101,7 @@ import { isTerminalLiveInputWithinByteLimit, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' +import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' @@ -1006,6 +1008,7 @@ export default function SessionScreen() { const viewportMeasuredRef = useRef(false) const terminalRefs = useRef>(new Map()) const liveInputRef = useRef(null) + const commandInputRef = useRef(null) const liveInputFocusTimerRef = useRef | null>(null) const sendLiveTerminalInputRef = useRef(async () => false) const sessionTabActionSheetKeyboardHideSubRef = useRef { + dismissTerminalKeyboard({ + clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef), + commandInput: commandInputRef.current, + dismissKeyboard: () => Keyboard.dismiss(), + liveInput: liveInputRef.current + }) + }, []) + const handleTerminalTap = useCallback( (handle: string) => { if (handle !== activeHandleRef.current) { @@ -4780,10 +4792,38 @@ export default function SessionScreen() { > {/* Accessory keys */} + {/* Why: a fixed, always-visible escape hatch from the open + keyboard. Kept outside the horizontal ScrollView so it does + not scroll away, and out of the terminal-byte shortcut path so + it cannot be hidden by user shortcut customization (#5106). */} + {keyboardLift > 0 && ( + [ + styles.keyboardDismissKey, + pressed && styles.accessoryKeyPressed + ]} + onPress={dismissSoftwareKeyboard} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Dismiss keyboard" + accessibilityHint="Hides the software keyboard and keeps the current terminal session open." + > + + + + + + )} {/* Why: with default tap handling the first tap on any accessory key dismisses the open keyboard and is swallowed, so live input lost its keyboard on every Esc/Tab press (#5106). */} { + it('clears pending live input focus before blur and dismiss', () => { + const calls: string[] = [] + const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear')) + const liveInput = { blur: vi.fn(() => calls.push('live-blur')) } + const commandInput = { blur: vi.fn(() => calls.push('command-blur')) } + const dismissKeyboard = vi.fn(() => calls.push('dismiss')) + + dismissTerminalKeyboard({ + clearPendingLiveInputFocus, + commandInput, + dismissKeyboard, + liveInput + }) + + expect(calls).toEqual(['clear', 'live-blur', 'command-blur', 'dismiss']) + }) + + it('blurs both live and buffered command inputs', () => { + const liveInput = { blur: vi.fn() } + const commandInput = { blur: vi.fn() } + + dismissTerminalKeyboard({ + clearPendingLiveInputFocus: vi.fn(), + commandInput, + dismissKeyboard: vi.fn(), + liveInput + }) + + expect(liveInput.blur).toHaveBeenCalledTimes(1) + expect(commandInput.blur).toHaveBeenCalledTimes(1) + }) + + it('dismisses the keyboard without a live input handle', () => { + const commandInput = { blur: vi.fn() } + const dismissKeyboard = vi.fn() + + dismissTerminalKeyboard({ + clearPendingLiveInputFocus: vi.fn(), + commandInput, + dismissKeyboard, + liveInput: null + }) + + expect(commandInput.blur).toHaveBeenCalledTimes(1) + expect(dismissKeyboard).toHaveBeenCalledTimes(1) + }) + + it('dismisses the keyboard without a buffered command input handle', () => { + const liveInput = { blur: vi.fn() } + const dismissKeyboard = vi.fn() + + dismissTerminalKeyboard({ + clearPendingLiveInputFocus: vi.fn(), + commandInput: undefined, + dismissKeyboard, + liveInput + }) + + expect(liveInput.blur).toHaveBeenCalledTimes(1) + expect(dismissKeyboard).toHaveBeenCalledTimes(1) + }) + + it('still clears focus and dismisses when both input handles are missing', () => { + const calls: string[] = [] + const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear')) + const dismissKeyboard = vi.fn(() => calls.push('dismiss')) + + dismissTerminalKeyboard({ + clearPendingLiveInputFocus, + commandInput: undefined, + dismissKeyboard, + liveInput: null + }) + + expect(calls).toEqual(['clear', 'dismiss']) + }) +}) diff --git a/mobile/src/terminal/terminal-keyboard-dismiss.ts b/mobile/src/terminal/terminal-keyboard-dismiss.ts new file mode 100644 index 000000000..502ed4275 --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-dismiss.ts @@ -0,0 +1,17 @@ +export type TerminalKeyboardDismissHandle = { blur: () => void } | null | undefined + +export type DismissTerminalKeyboardOptions = { + clearPendingLiveInputFocus: () => void + commandInput: TerminalKeyboardDismissHandle + dismissKeyboard: () => void + liveInput: TerminalKeyboardDismissHandle +} + +export function dismissTerminalKeyboard(options: DismissTerminalKeyboardOptions): void { + // Why: clear the queued live-input focus before blurring/dismissing so a + // pending deferred focus cannot re-open the iOS keyboard right after Hide. + options.clearPendingLiveInputFocus() + options.liveInput?.blur() + options.commandInput?.blur() + options.dismissKeyboard() +} diff --git a/mobile/vitest.config.ts b/mobile/vitest.config.ts index ae56f5bdd..9e7e3ecc5 100644 --- a/mobile/vitest.config.ts +++ b/mobile/vitest.config.ts @@ -1,25 +1,12 @@ import { defineConfig } from 'vitest/config' -const tsconfigRaw = JSON.stringify({ - compilerOptions: { - jsx: 'react-jsx', - module: 'esnext', - moduleResolution: 'bundler', - strict: true, - target: 'es2022' - } -}) +const vitestOxcConfig = { tsconfig: false } as never export default defineConfig({ root: import.meta.dirname, - esbuild: { - tsconfigRaw - }, - optimizeDeps: { - esbuildOptions: { - tsconfigRaw - } - }, + // Why: the app tsconfig intentionally excludes tests; Vite 8's OXC transform + // otherwise fails before Vitest can run the test modules. + oxc: vitestOxcConfig, test: { environment: 'node', include: ['src/**/*.test.ts']