From 60037d60ab871d858f4845df8fbef4ea34910f57 Mon Sep 17 00:00:00 2001 From: Wolfie Date: Wed, 8 Jul 2026 17:18:16 -0700 Subject: [PATCH] feat(mobile): add explicit keyboard dismiss control to terminal command dock (#5917) * feat(mobile): add explicit keyboard dismiss control to terminal command dock Add a fixed Hide control at the left of the terminal command dock accessory bar whenever the software keyboard is open (keyboardHeight > 0). Tapping it clears any pending live-input focus timer, blurs the live and buffered command inputs, and dismisses the keyboard without sending bytes, switching input mode, or clearing typed text. The dismiss behavior lives in a dedicated, unit-tested terminal-keyboard-dismiss module rather than the customizable accessory-key path, so the escape hatch cannot be hidden by user shortcut customization. Available on every platform where the IME covers the app (iOS and Android). * review: harden keyboard dismiss control per adversarial review - document the load-bearing clear-before-blur order in dismissTerminalKeyboard - cover the both-handles-missing case in unit tests (5/5) - move the #5106 first-tap comment onto the accessory ScrollView and add a why-comment for the fixed Hide control - add accessibilityRole=button and hitSlop to the Hide control for a larger, semantically-correct touch target * fix(mobile): harden hide button visibility and scroll layout * refactor(mobile): use stacked keyboard+chevron glyph for dismiss control Replace the icon+'Hide' text with the iOS-native dismiss glyph (keyboard with a chevron-down beneath it). Narrower in the accessory row, removes the icon/word redundancy, and reads as distinct from the >> input-mode toggle. Accessibility label/hint/role unchanged. * fix(mobile): align keyboard dismiss accessory height * test(mobile): align vitest transform with Vite 8 --------- Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Co-authored-by: Jinwoo-H --- .../app/h/[hostId]/session/[worktreeId].tsx | 41 ++++++++++ .../mobile-session-command-input-styles.ts | 29 +++++++ .../terminal-keyboard-dismiss.test.ts | 82 +++++++++++++++++++ .../src/terminal/terminal-keyboard-dismiss.ts | 17 ++++ mobile/vitest.config.ts | 21 +---- 5 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 mobile/src/terminal/terminal-keyboard-dismiss.test.ts create mode 100644 mobile/src/terminal/terminal-keyboard-dismiss.ts 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']