fix(mobile): focus Kimi terminal input after touch (#11865)
* fix(mobile): focus terminal input after TUI touch * fix(mobile): defer terminal focus after WebView taps * fix(mobile): reset deferred terminal focus on route blur
This commit is contained in:
parent
340faaa839
commit
c2e3d13efe
|
|
@ -117,10 +117,10 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi
|
|||
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
focusTerminalLiveInputTarget,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
scheduleTerminalLiveInputFocus
|
||||
} from '../../../../src/terminal/terminal-live-input'
|
||||
import { useTerminalLiveInputFocus } from '../../../../src/terminal/use-terminal-live-input-focus'
|
||||
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'
|
||||
|
|
@ -1082,6 +1082,22 @@ export default function SessionScreen() {
|
|||
activeSessionTabType: activeSessionTab?.type
|
||||
})
|
||||
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
|
||||
const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({
|
||||
activeHandleRef,
|
||||
canSend,
|
||||
inputRef: liveInputRef,
|
||||
keyboardHeight,
|
||||
lifecycleIdentity: client,
|
||||
lifecycleKey: JSON.stringify([hostId, worktreeId, connState]),
|
||||
liveInputEnabled,
|
||||
timerRef: liveInputFocusTimerRef
|
||||
})
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Expo retains this route while pushed screens are visible.
|
||||
return resetLiveInputFocus
|
||||
}, [resetLiveInputFocus])
|
||||
)
|
||||
const [browserScreencastSupported, setBrowserScreencastSupported] = useState<boolean | null>(null)
|
||||
// Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel.
|
||||
const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState<boolean | null>(
|
||||
|
|
@ -3092,17 +3108,6 @@ export default function SessionScreen() {
|
|||
)
|
||||
sendLiveTerminalInputRef.current = sendLiveTerminalInput
|
||||
|
||||
const focusLiveInput = useCallback(() => {
|
||||
if (!canSend || !liveInputEnabled) {
|
||||
return
|
||||
}
|
||||
focusTerminalLiveInputTarget(liveInputRef.current, {
|
||||
keyboardHeight,
|
||||
refocus: () =>
|
||||
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
|
||||
})
|
||||
}, [canSend, keyboardHeight, liveInputEnabled])
|
||||
|
||||
const clearSessionTabActionSheetKeyboardListener = useCallback(() => {
|
||||
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
|
||||
sessionTabActionSheetKeyboardHideSubRef.current = null
|
||||
|
|
@ -3177,16 +3182,6 @@ export default function SessionScreen() {
|
|||
})
|
||||
}, [])
|
||||
|
||||
const handleTerminalTap = useCallback(
|
||||
(handle: string) => {
|
||||
if (handle !== activeHandleRef.current) {
|
||||
return
|
||||
}
|
||||
focusLiveInput()
|
||||
},
|
||||
[focusLiveInput]
|
||||
)
|
||||
|
||||
// Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss.
|
||||
const handleFileTapActivationSeqRef = useRef(0)
|
||||
const handleFileTap = useCallback(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ const commandInputStylesSource = readFileSync(
|
|||
new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const liveInputFocusSource = readFileSync(
|
||||
new URL('./use-terminal-live-input-focus.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function liveInputBarBlock(): string {
|
||||
const start = sessionRouteSource.indexOf('{liveInputEnabled ? (')
|
||||
|
|
@ -35,9 +39,15 @@ describe('terminal live input affordance', () => {
|
|||
expect(block).toContain('pressed && styles.liveInputFocusTargetPressed')
|
||||
expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled')
|
||||
expect(block).toContain('showSoftInputOnFocus')
|
||||
expect(sessionRouteSource).toContain('focusTerminalLiveInputTarget(liveInputRef.current')
|
||||
expect(sessionRouteSource).toContain('keyboardHeight')
|
||||
expect(sessionRouteSource).toContain('scheduleTerminalLiveInputFocus(liveInputFocusTimerRef')
|
||||
expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({')
|
||||
expect(sessionRouteSource).toContain('return resetLiveInputFocus')
|
||||
expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current')
|
||||
expect(liveInputFocusSource).toContain('lifecycleIdentity,')
|
||||
expect(liveInputFocusSource).toContain('resetLiveInputFocus')
|
||||
expect(liveInputFocusSource).toContain('keyboardHeight: context.keyboardHeight')
|
||||
expect(liveInputFocusSource).toContain(
|
||||
'scheduleTerminalLiveInputFocus(timerRef, focusLiveInput)'
|
||||
)
|
||||
})
|
||||
|
||||
it('makes the live keyboard target visible instead of status-only chrome', () => {
|
||||
|
|
|
|||
|
|
@ -179,9 +179,8 @@ export const TERMINAL_MOUSE_CLICK_DRAG_JS = `
|
|||
// Why: a dismissing tap only clears the selection (touch parity); it must
|
||||
// not also open a link or focus the keyboard underneath.
|
||||
if (gesture.dismissedSelection) return;
|
||||
// Plain click == touch tap: links and file paths win, then tracking-mode
|
||||
// click reports, then keyboard focus.
|
||||
notifyTerminalSurfaceTap(e.clientX, e.clientY);
|
||||
// Pointer clicks keep their current link, file, TUI mouse, and focus priority.
|
||||
notifyTerminalSurfaceTap(e.clientX, e.clientY, false);
|
||||
}, true);
|
||||
|
||||
targetSurface.addEventListener('pointercancel', function(e) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ describe('terminal WebView external mouse click', () => {
|
|||
expect(bytes.slice(0, 4)).toBe(`${ESC}[M `)
|
||||
expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`)
|
||||
expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12))
|
||||
expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([])
|
||||
})
|
||||
|
||||
it('dismisses an existing selection with a click without focusing the keyboard', () => {
|
||||
|
|
|
|||
|
|
@ -212,10 +212,12 @@ describe('TerminalWebView scroll routing', () => {
|
|||
"document.addEventListener('touchend'",
|
||||
'}, { capture: true, passive: true });'
|
||||
)
|
||||
expect(touchEndBlock).toContain('notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y)')
|
||||
expect(touchEndBlock).toContain(
|
||||
'notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true)'
|
||||
)
|
||||
|
||||
const tapHandlerBlock = sliceBetween(
|
||||
'function notifyTerminalSurfaceTap(originX, originY)',
|
||||
'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)',
|
||||
"document.addEventListener('touchstart'"
|
||||
)
|
||||
expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan(
|
||||
|
|
@ -229,6 +231,9 @@ describe('TerminalWebView scroll routing', () => {
|
|||
)
|
||||
expect(tapHandlerBlock).toContain("notify({ type: 'open-url', url: tappedUrl });")
|
||||
expect(tapHandlerBlock).toContain("notify({ type: 'terminal-input', bytes: clickInput });")
|
||||
expect(tapHandlerBlock).toContain(
|
||||
'if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode()))'
|
||||
)
|
||||
expect(tapHandlerBlock).toContain("notify({ type: 'terminal-tap' });")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ export const TERMINAL_TAP_DISPATCH_JS = `
|
|||
selMode !== 'select' &&
|
||||
Date.now() - tapCandidate.t <= TAP_MAX_MS
|
||||
) {
|
||||
notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y);
|
||||
notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true);
|
||||
}
|
||||
clearLongPress();
|
||||
tapCandidate = null;
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ function bodyMarkup(): string {
|
|||
}
|
||||
|
||||
// Minimal xterm stub: one scrollback line containing a URL, fixed 8x15 cells.
|
||||
function makeTerminal(lineRef: { current: string }) {
|
||||
function makeTerminal(lineRef: { current: string }, mouseTrackingMode = 'none') {
|
||||
return {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: { fontSize: 13 },
|
||||
modes: {},
|
||||
modes: { mouseTrackingMode },
|
||||
element: { scrollWidth: 800, scrollHeight: 360 },
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } },
|
||||
buffer: {
|
||||
|
|
@ -76,13 +76,14 @@ type OscLinkRange = { row: number; startCol: number; endCol: number; uri: string
|
|||
|
||||
function boot(
|
||||
line: string,
|
||||
oscLinks?: OscLinkRange[]
|
||||
oscLinks?: OscLinkRange[],
|
||||
mouseTrackingMode = 'none'
|
||||
): { posted: Posted; setLine: (line: string) => void } {
|
||||
const posted: Posted = []
|
||||
const lineRef = { current: line }
|
||||
const w = window as unknown as { Terminal: unknown; ReactNativeWebView: unknown }
|
||||
w.Terminal = function () {
|
||||
return makeTerminal(lineRef)
|
||||
return makeTerminal(lineRef, mouseTrackingMode)
|
||||
}
|
||||
w.ReactNativeWebView = {
|
||||
postMessage(s: string) {
|
||||
|
|
@ -150,6 +151,31 @@ describe('terminal WebView tap routing', () => {
|
|||
expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/foo')
|
||||
})
|
||||
|
||||
it('focuses native input after reporting a touch tap to a mouse-tracking TUI', async () => {
|
||||
const { posted } = boot('interactive prompt', undefined, 'drag')
|
||||
await settle()
|
||||
|
||||
fireTouch('touchstart', [{ x: 20, y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
|
||||
expect(
|
||||
posted
|
||||
.filter((message) => message.type === 'terminal-input' || message.type === 'terminal-tap')
|
||||
.map((message) => message.type)
|
||||
).toEqual(['terminal-input', 'terminal-tap'])
|
||||
})
|
||||
|
||||
it('reports a non-mouse touch tap without terminal mouse bytes', async () => {
|
||||
const { posted } = boot('plain prompt')
|
||||
await settle()
|
||||
|
||||
fireTouch('touchstart', [{ x: 20, y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
|
||||
expect(posted.find((message) => message.type === 'terminal-input')).toBeUndefined()
|
||||
expect(posted.filter((message) => message.type === 'terminal-tap')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens the URL even right after a width-change reflow', async () => {
|
||||
// Why: scrollback reflow rewraps the local buffer; the tap path must keep
|
||||
// working afterward (the reflow message must not disturb tap routing).
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export const URL_TAP_WEBVIEW_JS = `
|
|||
} catch (e) { return 0; }
|
||||
}
|
||||
|
||||
function notifyTerminalSurfaceTap(originX, originY) {
|
||||
function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) {
|
||||
var tappedOscLink = oscLinkAtViewportPoint(originX, originY);
|
||||
if (tappedOscLink && tappedOscLink.kind === 'file') {
|
||||
notify({
|
||||
|
|
@ -245,7 +245,9 @@ export const URL_TAP_WEBVIEW_JS = `
|
|||
var clickInput = buildMouseClickInput(originX, originY);
|
||||
if (clickInput) {
|
||||
notify({ type: 'terminal-input', bytes: clickInput });
|
||||
} else if (!isClickMouseTrackingMode(getMouseTrackingMode())) {
|
||||
}
|
||||
// Touch still needs native input focus after the TUI consumes its mouse click.
|
||||
if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) {
|
||||
notify({ type: 'terminal-tap' });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,252 @@
|
|||
import { createElement, type RefObject } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
TerminalLiveInputFocusTarget,
|
||||
TerminalLiveInputFocusTimerRef
|
||||
} from './terminal-live-input'
|
||||
import { useTerminalLiveInputFocus } from './use-terminal-live-input-focus'
|
||||
|
||||
type HarnessProps = {
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
readonly canSend: boolean
|
||||
readonly inputRef: RefObject<TerminalLiveInputFocusTarget | null>
|
||||
readonly keyboardHeight?: number
|
||||
readonly lifecycleIdentity: object | null
|
||||
readonly lifecycleKey: string
|
||||
readonly liveInputEnabled: boolean
|
||||
readonly timerRef: TerminalLiveInputFocusTimerRef
|
||||
}
|
||||
|
||||
type FocusHandlers = ReturnType<typeof useTerminalLiveInputFocus>
|
||||
|
||||
function suppressReactTestRendererWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
function createFocusTarget(initiallyFocused = false): TerminalLiveInputFocusTarget & {
|
||||
readonly blur: ReturnType<typeof vi.fn>
|
||||
readonly focus: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
let focused = initiallyFocused
|
||||
return {
|
||||
blur: vi.fn(() => {
|
||||
focused = false
|
||||
}),
|
||||
focus: vi.fn(() => {
|
||||
focused = true
|
||||
}),
|
||||
isFocused: () => focused
|
||||
}
|
||||
}
|
||||
|
||||
function createTimerRef(): TerminalLiveInputFocusTimerRef {
|
||||
return { current: null }
|
||||
}
|
||||
|
||||
function createHarness(initialProps: HarnessProps): {
|
||||
readonly handlers: () => FocusHandlers
|
||||
readonly render: (props: HarnessProps) => void
|
||||
readonly unmount: () => void
|
||||
} {
|
||||
let handlers: FocusHandlers | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Harness(props: HarnessProps): null {
|
||||
handlers = useTerminalLiveInputFocus({
|
||||
...props,
|
||||
keyboardHeight: props.keyboardHeight ?? 0
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreWarning = suppressReactTestRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, initialProps))
|
||||
})
|
||||
} finally {
|
||||
restoreWarning()
|
||||
}
|
||||
if (!handlers || !renderer) {
|
||||
throw new Error('terminal live input focus harness did not render')
|
||||
}
|
||||
|
||||
return {
|
||||
handlers: () => {
|
||||
if (!handlers) {
|
||||
throw new Error('terminal live input focus harness is not mounted')
|
||||
}
|
||||
return handlers
|
||||
},
|
||||
render: (props) => {
|
||||
act(() => renderer?.update(createElement(Harness, props)))
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => renderer?.unmount())
|
||||
handlers = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectedProps(
|
||||
inputRef: RefObject<TerminalLiveInputFocusTarget | null>,
|
||||
timerRef = createTimerRef(),
|
||||
activeHandleRef: RefObject<string | null> = { current: 'terminal-a' }
|
||||
): HarnessProps {
|
||||
return {
|
||||
activeHandleRef,
|
||||
canSend: true,
|
||||
inputRef,
|
||||
lifecycleIdentity: null,
|
||||
lifecycleKey: 'host-a:worktree-a:connected',
|
||||
liveInputEnabled: true,
|
||||
timerRef
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal live input focus hook', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('defers initial terminal surface focus until the WebView touch has completed', () => {
|
||||
vi.useFakeTimers()
|
||||
const input = createFocusTarget()
|
||||
const harness = createHarness(connectedProps({ current: input }))
|
||||
|
||||
harness.handlers().handleTerminalTap('terminal-a')
|
||||
expect(input.focus).not.toHaveBeenCalled()
|
||||
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(input.focus).toHaveBeenCalledTimes(1)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('reacquires focus for mouse-aware and non-mouse terminal tap notifications', () => {
|
||||
vi.useFakeTimers()
|
||||
const input = createFocusTarget()
|
||||
const harness = createHarness(connectedProps({ current: input }))
|
||||
|
||||
harness.handlers().handleTerminalTap('terminal-a')
|
||||
vi.runOnlyPendingTimers()
|
||||
harness.handlers().handleTerminalTap('terminal-a')
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(input.blur).toHaveBeenCalledTimes(1)
|
||||
expect(input.focus).toHaveBeenCalledTimes(2)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('cancels focus when navigation reuses the mounted route', () => {
|
||||
vi.useFakeTimers()
|
||||
const oldInput = createFocusTarget()
|
||||
const inputRef: RefObject<TerminalLiveInputFocusTarget | null> = { current: oldInput }
|
||||
const timerRef = createTimerRef()
|
||||
const harness = createHarness(connectedProps(inputRef, timerRef))
|
||||
harness.handlers().handleTerminalTap('terminal-a')
|
||||
const newInput = createFocusTarget()
|
||||
inputRef.current = newInput
|
||||
harness.render({
|
||||
...connectedProps(inputRef, timerRef),
|
||||
lifecycleKey: 'host-a:worktree-b:connected'
|
||||
})
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(oldInput.focus).not.toHaveBeenCalled()
|
||||
expect(newInput.focus).not.toHaveBeenCalled()
|
||||
|
||||
harness.handlers().handleTerminalTap('terminal-a')
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(newInput.focus).toHaveBeenCalledTimes(1)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('drops pending focus when reconnect reuses the mounted route', () => {
|
||||
vi.useFakeTimers()
|
||||
const oldInput = createFocusTarget()
|
||||
const oldInputRef = { current: oldInput }
|
||||
const timerRef = createTimerRef()
|
||||
const harness = createHarness(connectedProps(oldInputRef, timerRef))
|
||||
harness.handlers().handleTerminalTap('terminal-a')
|
||||
harness.render({
|
||||
...connectedProps(oldInputRef, timerRef),
|
||||
canSend: false,
|
||||
lifecycleIdentity: {},
|
||||
lifecycleKey: 'host-a:worktree-a:disconnected'
|
||||
})
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(oldInput.focus).not.toHaveBeenCalled()
|
||||
|
||||
const replacementInput = createFocusTarget()
|
||||
oldInputRef.current = replacementInput
|
||||
harness.render({
|
||||
...connectedProps(oldInputRef, timerRef),
|
||||
lifecycleIdentity: {},
|
||||
lifecycleKey: 'host-a:worktree-a:connected'
|
||||
})
|
||||
harness.handlers().handleTerminalTap('terminal-a')
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(replacementInput.focus).toHaveBeenCalledTimes(1)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('cancels focus when a retained screen blurs', () => {
|
||||
vi.useFakeTimers()
|
||||
const input = createFocusTarget()
|
||||
const timerRef = createTimerRef()
|
||||
const harness = createHarness(connectedProps({ current: input }, timerRef))
|
||||
|
||||
harness.handlers().handleTerminalTap('terminal-a')
|
||||
harness.handlers().resetLiveInputFocus()
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(timerRef.current).toBeNull()
|
||||
expect(input.blur).toHaveBeenCalledTimes(1)
|
||||
expect(input.focus).not.toHaveBeenCalled()
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('does not let stale handle state focus a replacement terminal', () => {
|
||||
vi.useFakeTimers()
|
||||
const firstInput = createFocusTarget()
|
||||
const inputRef: RefObject<TerminalLiveInputFocusTarget | null> = { current: firstInput }
|
||||
const timerRef = createTimerRef()
|
||||
const activeHandleRef = { current: 'terminal-a' as string | null }
|
||||
const harness = createHarness(connectedProps(inputRef, timerRef, activeHandleRef))
|
||||
harness.handlers().handleTerminalTap('terminal-a')
|
||||
|
||||
const replacementInput = createFocusTarget()
|
||||
inputRef.current = replacementInput
|
||||
activeHandleRef.current = 'terminal-b'
|
||||
harness.render(connectedProps(inputRef, timerRef, activeHandleRef))
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(firstInput.focus).not.toHaveBeenCalled()
|
||||
expect(replacementInput.focus).not.toHaveBeenCalled()
|
||||
harness.handlers().handleTerminalTap('terminal-b')
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(replacementInput.focus).toHaveBeenCalledTimes(1)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('keeps the native focus target immediate outside the WebView tap path', () => {
|
||||
const input = createFocusTarget()
|
||||
const harness = createHarness(connectedProps({ current: input }))
|
||||
|
||||
harness.handlers().focusLiveInput()
|
||||
|
||||
expect(input.focus).toHaveBeenCalledTimes(1)
|
||||
harness.unmount()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
focusTerminalLiveInputTarget,
|
||||
scheduleTerminalLiveInputFocus,
|
||||
type TerminalLiveInputFocusTarget,
|
||||
type TerminalLiveInputFocusTimerRef
|
||||
} from './terminal-live-input'
|
||||
|
||||
type TerminalLiveInputFocusContext = {
|
||||
readonly canSend: boolean
|
||||
readonly keyboardHeight: number
|
||||
readonly liveInputEnabled: boolean
|
||||
}
|
||||
|
||||
type UseTerminalLiveInputFocusOptions<T extends TerminalLiveInputFocusTarget> =
|
||||
TerminalLiveInputFocusContext & {
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
readonly inputRef: RefObject<T | null>
|
||||
readonly lifecycleIdentity: object | null
|
||||
readonly lifecycleKey: string
|
||||
readonly timerRef: TerminalLiveInputFocusTimerRef
|
||||
}
|
||||
|
||||
type TerminalLiveInputFocusHandlers = {
|
||||
readonly focusLiveInput: () => void
|
||||
readonly handleTerminalTap: (handle: string) => void
|
||||
readonly resetLiveInputFocus: () => void
|
||||
}
|
||||
|
||||
export function useTerminalLiveInputFocus<T extends TerminalLiveInputFocusTarget>({
|
||||
activeHandleRef,
|
||||
canSend,
|
||||
inputRef,
|
||||
keyboardHeight,
|
||||
lifecycleIdentity,
|
||||
lifecycleKey,
|
||||
liveInputEnabled,
|
||||
timerRef
|
||||
}: UseTerminalLiveInputFocusOptions<T>): TerminalLiveInputFocusHandlers {
|
||||
const contextRef = useRef<TerminalLiveInputFocusContext>({
|
||||
canSend,
|
||||
keyboardHeight,
|
||||
liveInputEnabled
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
contextRef.current = { canSend, keyboardHeight, liveInputEnabled }
|
||||
}, [canSend, keyboardHeight, liveInputEnabled])
|
||||
|
||||
const resetLiveInputFocus = useCallback(() => {
|
||||
clearTerminalLiveInputFocusTimer(timerRef)
|
||||
inputRef.current?.blur()
|
||||
}, [inputRef, timerRef])
|
||||
|
||||
// Retained Expo routes must not carry focus work across navigation or reconnect scopes.
|
||||
useLayoutEffect(() => resetLiveInputFocus, [lifecycleIdentity, lifecycleKey, resetLiveInputFocus])
|
||||
|
||||
const focusLiveInput = useCallback(() => {
|
||||
const context = contextRef.current
|
||||
if (!context.canSend || !context.liveInputEnabled) {
|
||||
return
|
||||
}
|
||||
focusTerminalLiveInputTarget(inputRef.current, {
|
||||
keyboardHeight: context.keyboardHeight,
|
||||
refocus: () => scheduleTerminalLiveInputFocus(timerRef, focusLiveInput)
|
||||
})
|
||||
}, [inputRef, timerRef])
|
||||
|
||||
const handleTerminalTap = useCallback(
|
||||
(handle: string) => {
|
||||
const context = contextRef.current
|
||||
if (handle !== activeHandleRef.current || !context.canSend || !context.liveInputEnabled) {
|
||||
return
|
||||
}
|
||||
// WKWebView still owns first responder during its touchend notification.
|
||||
scheduleTerminalLiveInputFocus(timerRef, () => {
|
||||
if (activeHandleRef.current === handle) {
|
||||
focusLiveInput()
|
||||
}
|
||||
})
|
||||
},
|
||||
[activeHandleRef, focusLiveInput, timerRef]
|
||||
)
|
||||
|
||||
return { focusLiveInput, handleTerminalTap, resetLiveInputFocus }
|
||||
}
|
||||
Loading…
Reference in New Issue