diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index c6e5f434326..e1ea2f8ec15 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -79,7 +79,7 @@ const HEAD_TIMER_CREATION_SHA256 = '1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b' const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116' const HEAD_RUNTIME_STRING_SHA256 = - 'ba52a3ede721bd29acbe8593161e90b216b7f361ff896e085927d4d73fa83b2f' + '0c08a53c2cd1e182e1d7edfb7b98bd9e4a313e47c7b93f5a509a89ec3292bc1f' const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5' const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016' const HEAD_STYLE_REFERENCE_SHA256 = @@ -517,7 +517,7 @@ describe('mobile session route extraction parity', () => { it('preserves runtime strings, styles, and the expanded JSX tree', () => { const strings = readRuntimeStrings() - expect(strings).toHaveLength(546) + expect(strings).toHaveLength(547) expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) const jsx = readJsxFacts(readDefinitions()) expect(jsx.host).toHaveLength(124) diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts index 5086efe1ba1..374a9eefd94 100644 --- a/mobile/src/session/use-mobile-session-terminal-runtime.ts +++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts @@ -1,5 +1,5 @@ import { useState, useRef, useCallback } from 'react' -import type { Keyboard, TextInput } from 'react-native' +import { Platform, type Keyboard, type TextInput } from 'react-native' import { useFocusEffect } from 'expo-router' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -142,6 +142,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM lifecycleIdentity: client, lifecycleKey: JSON.stringify([hostId, worktreeId, connState]), liveInputEnabled, + reopenFocusedInputWhenKeyboardHidden: Platform.OS === 'android', timerRef: liveInputFocusTimerRef }) useFocusEffect( diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index daca212d4a8..a9ec903f637 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -196,18 +196,40 @@ describe('terminal live input', () => { const input = createFocusTarget(() => true) const refocus = vi.fn() - focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus }) + focusTerminalLiveInputTarget(input, { + keyboardHeight: 0, + refocus, + reopenFocusedInputWhenKeyboardHidden: true + }) expect(input.blur).toHaveBeenCalledTimes(1) expect(input.focus).not.toHaveBeenCalled() expect(refocus).toHaveBeenCalledTimes(1) }) + it('keeps an iPad hardware-keyboard responder focused when the software keyboard is absent', () => { + const input = createFocusTarget(() => true) + const refocus = vi.fn() + + focusTerminalLiveInputTarget(input, { + keyboardHeight: 0, + refocus, + reopenFocusedInputWhenKeyboardHidden: false + }) + + expect(input.blur).not.toHaveBeenCalled() + expect(input.focus).toHaveBeenCalledTimes(1) + expect(refocus).not.toHaveBeenCalled() + }) it('focuses the capture input directly when the keyboard is open', () => { const input = createFocusTarget(() => true) const refocus = vi.fn() - focusTerminalLiveInputTarget(input, { keyboardHeight: 240, refocus }) + focusTerminalLiveInputTarget(input, { + keyboardHeight: 240, + refocus, + reopenFocusedInputWhenKeyboardHidden: true + }) expect(input.blur).not.toHaveBeenCalled() expect(input.focus).toHaveBeenCalledTimes(1) @@ -218,7 +240,11 @@ describe('terminal live input', () => { const input = createFocusTarget(() => false) const refocus = vi.fn() - focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus }) + focusTerminalLiveInputTarget(input, { + keyboardHeight: 0, + refocus, + reopenFocusedInputWhenKeyboardHidden: true + }) expect(input.blur).not.toHaveBeenCalled() expect(input.focus).toHaveBeenCalledTimes(1) diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index 321226c0c7d..42b4d5cb2cf 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -75,6 +75,7 @@ export type TerminalLiveInputFocusTarget = { type FocusTerminalLiveInputTargetOptions = { readonly keyboardHeight: number readonly refocus: () => void + readonly reopenFocusedInputWhenKeyboardHidden: boolean } export type TerminalLiveInputDefaultResult = { @@ -230,13 +231,17 @@ export function scheduleTerminalLiveInputFocus( export function focusTerminalLiveInputTarget( input: TerminalLiveInputFocusTarget | null, - { keyboardHeight, refocus }: FocusTerminalLiveInputTargetOptions + { + keyboardHeight, + refocus, + reopenFocusedInputWhenKeyboardHidden + }: FocusTerminalLiveInputTargetOptions ): void { if (!input) { return } - if (keyboardHeight <= 0 && input.isFocused?.()) { + if (reopenFocusedInputWhenKeyboardHidden && keyboardHeight <= 0 && input.isFocused?.()) { // Why: Android can keep a hidden TextInput focused after the IME is dismissed; // focus() is then a no-op, so force a new focus session to reopen the keyboard. input.blur() diff --git a/mobile/src/terminal/use-terminal-live-input-focus.test.ts b/mobile/src/terminal/use-terminal-live-input-focus.test.ts index 92ce4e2c455..cb906b164c7 100644 --- a/mobile/src/terminal/use-terminal-live-input-focus.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-focus.test.ts @@ -15,6 +15,7 @@ type HarnessProps = { readonly lifecycleIdentity: object | null readonly lifecycleKey: string readonly liveInputEnabled: boolean + readonly reopenFocusedInputWhenKeyboardHidden: boolean readonly timerRef: TerminalLiveInputFocusTimerRef } @@ -92,6 +93,7 @@ function connectedProps( lifecycleIdentity: null, lifecycleKey: 'host-a:worktree-a:connected', liveInputEnabled: true, + reopenFocusedInputWhenKeyboardHidden: true, timerRef } } @@ -128,6 +130,22 @@ describe('terminal live input focus hook', () => { expect(input.focus).toHaveBeenCalledTimes(2) harness.unmount() }) + it('preserves the focused iPad responder when a hardware keyboard keeps keyboard height at zero', () => { + vi.useFakeTimers() + const input = createFocusTarget(true) + const inputRef = { current: input } + const harness = createHarness({ + ...connectedProps(inputRef), + reopenFocusedInputWhenKeyboardHidden: false + }) + + harness.handlers().handleTerminalTap('terminal-a') + vi.runAllTimers() + + expect(input.blur).not.toHaveBeenCalled() + expect(input.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) it('cancels focus when navigation reuses the mounted route', () => { vi.useFakeTimers() diff --git a/mobile/src/terminal/use-terminal-live-input-focus.ts b/mobile/src/terminal/use-terminal-live-input-focus.ts index b0ab456b721..36fa0f5272e 100644 --- a/mobile/src/terminal/use-terminal-live-input-focus.ts +++ b/mobile/src/terminal/use-terminal-live-input-focus.ts @@ -11,6 +11,7 @@ type TerminalLiveInputFocusContext = { readonly canSend: boolean readonly keyboardHeight: number readonly liveInputEnabled: boolean + readonly reopenFocusedInputWhenKeyboardHidden: boolean } type UseTerminalLiveInputFocusOptions = @@ -35,17 +36,24 @@ export function useTerminalLiveInputFocus): TerminalLiveInputFocusHandlers { const contextRef = useRef({ canSend, keyboardHeight, - liveInputEnabled + liveInputEnabled, + reopenFocusedInputWhenKeyboardHidden }) useLayoutEffect(() => { - contextRef.current = { canSend, keyboardHeight, liveInputEnabled } - }, [canSend, keyboardHeight, liveInputEnabled]) + contextRef.current = { + canSend, + keyboardHeight, + liveInputEnabled, + reopenFocusedInputWhenKeyboardHidden + } + }, [canSend, keyboardHeight, liveInputEnabled, reopenFocusedInputWhenKeyboardHidden]) const resetLiveInputFocus = useCallback(() => { clearTerminalLiveInputFocusTimer(timerRef) @@ -62,6 +70,7 @@ export function useTerminalLiveInputFocus scheduleTerminalLiveInputFocus(timerRef, focusLiveInput) }) }, [inputRef, timerRef])