fix(mobile): bound live terminal input latency (#12763)
This commit is contained in:
parent
be2f9eddd3
commit
73cd4c3f46
|
|
@ -4986,6 +4986,7 @@ export default function SessionScreen() {
|
|||
<MobileTerminalLiveInputStatus
|
||||
dictation={dictation}
|
||||
isAttaching={isAttaching}
|
||||
liveInputText={liveInputCapture}
|
||||
/>
|
||||
</Pressable>
|
||||
<MobileTerminalInputActions
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue