fix(mobile): preserve iPad hardware keyboard focus (#12772)

* fix(mobile): preserve iPad terminal input focus

* Keep incoming main test formatting unchanged

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Ramiro
2026-09-06 00:08:27 -07:00
committed by GitHub
co-authored by Neil
parent a567e33bf7
commit b51bbf3fc6
6 changed files with 70 additions and 11 deletions
@@ -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)
@@ -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(
@@ -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)
+7 -2
View File
@@ -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()
@@ -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()
@@ -11,6 +11,7 @@ type TerminalLiveInputFocusContext = {
readonly canSend: boolean
readonly keyboardHeight: number
readonly liveInputEnabled: boolean
readonly reopenFocusedInputWhenKeyboardHidden: boolean
}
type UseTerminalLiveInputFocusOptions<T extends TerminalLiveInputFocusTarget> =
@@ -35,17 +36,24 @@ export function useTerminalLiveInputFocus<T extends TerminalLiveInputFocusTarget
keyboardHeight,
lifecycleIdentity,
lifecycleKey,
reopenFocusedInputWhenKeyboardHidden,
liveInputEnabled,
timerRef
}: UseTerminalLiveInputFocusOptions<T>): TerminalLiveInputFocusHandlers {
const contextRef = useRef<TerminalLiveInputFocusContext>({
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<T extends TerminalLiveInputFocusTarget
}
focusTerminalLiveInputTarget(inputRef.current, {
keyboardHeight: context.keyboardHeight,
reopenFocusedInputWhenKeyboardHidden: context.reopenFocusedInputWhenKeyboardHidden,
refocus: () => scheduleTerminalLiveInputFocus(timerRef, focusLiveInput)
})
}, [inputRef, timerRef])