fix(mobile): bound live terminal input latency (#12763)

This commit is contained in:
Jinwoo Hong 2026-08-05 13:52:14 -07:00 committed by GitHub
parent be2f9eddd3
commit 73cd4c3f46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 184 additions and 40 deletions

View File

@ -4986,6 +4986,7 @@ export default function SessionScreen() {
<MobileTerminalLiveInputStatus
dictation={dictation}
isAttaching={isAttaching}
liveInputText={liveInputCapture}
/>
</Pressable>
<MobileTerminalInputActions

View File

@ -10,11 +10,13 @@ type DictationStatus = {
type MobileTerminalLiveInputStatusProps = {
readonly dictation: DictationStatus
readonly isAttaching: boolean
readonly liveInputText: string
}
export function MobileTerminalLiveInputStatus({
dictation,
isAttaching
isAttaching,
liveInputText
}: MobileTerminalLiveInputStatusProps) {
const title = dictation.isRecording
? 'Listening'
@ -31,14 +33,14 @@ export function MobileTerminalLiveInputStatus({
? 'Preparing microphone'
: isAttaching
? 'Uploading image to host'
: 'Tap to show keyboard'
: liveInputText || 'Tap to show keyboard'
return (
<View style={styles.status}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
<Text style={styles.detail} numberOfLines={1}>
<Text style={styles.detail} numberOfLines={1} ellipsizeMode="head">
{detail}
</Text>
</View>

View File

@ -39,6 +39,7 @@ describe('terminal live input affordance', () => {
expect(block).toContain('pressed && styles.liveInputFocusTargetPressed')
expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled')
expect(block).toContain('showSoftInputOnFocus')
expect(block).toContain('liveInputText={liveInputCapture}')
expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({')
expect(sessionRouteSource).toContain('return resetLiveInputFocus')
expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current')
@ -52,6 +53,8 @@ describe('terminal live input affordance', () => {
it('makes the live keyboard target visible instead of status-only chrome', () => {
expect(liveInputStatusSource).toContain("'Tap to show keyboard'")
expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'")
expect(liveInputStatusSource).toContain('ellipsizeMode="head"')
expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised')
expect(commandInputStylesSource).toContain('borderWidth: 1')
expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed')

View File

@ -1,15 +1,16 @@
import { describe, expect, it } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import {
cancelTerminalLivePendingFlush,
createTerminalLivePendingFlushState,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush,
type TerminalLivePendingFlushState
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
describe('terminal live pending flush state', () => {
it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
const state = createTerminalLivePendingFlushState()
// When / Then
await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
@ -22,7 +23,8 @@ describe('terminal live pending flush state', () => {
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: flushPromise }
const state = createTerminalLivePendingFlushState()
state.current = flushPromise
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
@ -48,7 +50,8 @@ describe('terminal live pending flush state', () => {
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: flushPromise }
const state = createTerminalLivePendingFlushState()
state.current = flushPromise
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
@ -67,17 +70,45 @@ describe('terminal live pending flush state', () => {
})
describe('terminal live mirror send queue', () => {
it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => {
// Given
const state = createTerminalLivePendingFlushState()
const payloads: string[] = []
let resolveFirstSend: (value: boolean) => void = () => {}
const sender = async (_handle: string, payload: string): Promise<boolean> => {
payloads.push(payload)
if (payloads.length === 1) {
return new Promise<boolean>((resolve) => {
resolveFirstSend = resolve
})
}
return true
}
// When
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender)
await Promise.resolve()
// Then
expect(payloads).toEqual(['a'])
resolveFirstSend(true)
await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true])
expect(payloads).toEqual(['a', 'bc'])
})
it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
const state = createTerminalLivePendingFlushState()
const order: string[] = []
const first = queueTerminalLiveMirrorSend(state, async () => {
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
order.push('first')
return false
})
// When
const second = queueTerminalLiveMirrorSend(state, async () => {
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => {
order.push('second')
return true
})
@ -90,13 +121,13 @@ describe('terminal live mirror send queue', () => {
it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
const first = queueTerminalLiveMirrorSend(state, async () => {
const state = createTerminalLivePendingFlushState()
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
throw new Error('boom')
})
// When
const second = queueTerminalLiveMirrorSend(state, async () => true)
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true)
// Then
await expect(first).resolves.toBe(false)
@ -105,13 +136,33 @@ describe('terminal live mirror send queue', () => {
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
const state = createTerminalLivePendingFlushState()
// When
await queueTerminalLiveMirrorSend(state, async () => true)
await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true)
await Promise.resolve()
// Then
expect(state.current).toBeNull()
})
it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => {
// Given
const state = createTerminalLivePendingFlushState()
let resolveSend: (value: boolean) => void = () => {}
const sender = async (): Promise<boolean> =>
new Promise((resolve) => {
resolveSend = resolve
})
const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
// When
cancelTerminalLivePendingFlush(state)
// Then
await expect(Promise.all([active, pending])).resolves.toEqual([false, false])
expect(state.current).toBeNull()
resolveSend(true)
})
})

View File

@ -1,5 +1,30 @@
type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise<boolean>
type TerminalLivePendingRequest = {
readonly resolve: (sent: boolean) => void
}
type TerminalLivePendingBatch = {
readonly handle: string
payload: string
readonly requests: TerminalLivePendingRequest[]
readonly sender: TerminalLiveMirrorSender
}
export type TerminalLivePendingFlushState = {
current: Promise<boolean> | null
activeRequests: TerminalLivePendingRequest[]
generation: number
pendingBatches: TerminalLivePendingBatch[]
}
export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState {
return {
current: null,
activeRequests: [],
generation: 0,
pendingBatches: []
}
}
export function waitForTerminalLivePendingFlush(
@ -8,26 +33,76 @@ export function waitForTerminalLivePendingFlush(
return state.current ?? Promise.resolve(true)
}
// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped
// delta desyncs every later diff, so this chain runs each send even when the
// previous one failed. state.current should never reject; the catch keeps a
// future raw assignment from skipping a delta.
export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void {
state.generation += 1
const requests = [
...state.activeRequests,
...state.pendingBatches.flatMap((batch) => batch.requests)
]
state.activeRequests = []
state.pendingBatches = []
state.current = null
requests.forEach(({ resolve }) => resolve(false))
}
async function drainTerminalLiveMirrorSends(
state: TerminalLivePendingFlushState,
generation: number
): Promise<boolean> {
let allSent = true
while (state.generation === generation) {
const batch = state.pendingBatches.shift()
if (!batch) {
state.current = null
return allSent
}
state.activeRequests = batch.requests
const sent = await batch.sender(batch.handle, batch.payload).catch(() => false)
if (state.generation !== generation) {
return false
}
state.activeRequests = []
batch.requests.forEach(({ resolve }) => resolve(sent))
allSent &&= sent
}
return false
}
// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke.
export function queueTerminalLiveMirrorSend(
state: TerminalLivePendingFlushState,
sendMirrorPayload: () => Promise<boolean>
handle: string,
payload: string,
sender: TerminalLiveMirrorSender
): Promise<boolean> {
const previousSend = state.current
const sendPromise = (async () => {
if (previousSend) {
await previousSend.catch(() => false)
}
return sendMirrorPayload()
})().catch(() => false)
state.current = sendPromise
void sendPromise.then(() => {
if (state.current === sendPromise) {
state.current = null
}
let resolveRequest: (sent: boolean) => void = () => {}
const request = new Promise<boolean>((resolve) => {
resolveRequest = resolve
})
return sendPromise
const pendingTail = state.pendingBatches.at(-1)
if (pendingTail?.handle === handle && pendingTail.sender === sender) {
pendingTail.payload += payload
pendingTail.requests.push({ resolve: resolveRequest })
} else {
state.pendingBatches.push({
handle,
payload,
requests: [{ resolve: resolveRequest }],
sender
})
}
if (!state.current) {
const generation = state.generation
const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false)
state.current = drain
void drain.then(() => {
if (state.current === drain) {
state.current = null
}
})
}
return request
}

View File

@ -7,6 +7,8 @@ import {
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
} from './terminal-live-hangul-mirror'
import {
cancelTerminalLivePendingFlush,
createTerminalLivePendingFlushState,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
@ -39,7 +41,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
setLiveInputCapture
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
const heldLiveInputTextRef = useRef('')
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
const resetMirrorState = useCallback(() => {
clearHeldCommitTimer()
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}, [])
const sendQueuedMirrorPayload = useCallback(
(handle: string, payload: string): Promise<boolean> =>
sendLiveTerminalInputRef.current(handle, payload),
[sendLiveTerminalInputRef]
)
const runMirrorStep = useCallback(
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
if (
@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
if (payload.length === 0) {
return waitForPendingLiveInputFlush()
}
return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () =>
sendLiveTerminalInputRef.current(handle, payload)
return queueTerminalLiveMirrorSend(
pendingLiveInputFlushRef.current,
handle,
payload,
sendQueuedMirrorPayload
)
},
[
@ -117,7 +129,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
clearHeldCommitTimer,
liveInputTerminalHandlesRef,
resetMirrorState,
sendLiveTerminalInputRef,
sendQueuedMirrorPayload,
waitForPendingLiveInputFlush
]
)
@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
pendingLiveInputFlushRef.current = null
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}
}, [])