mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
fix(mobile): backpressure accessory key repeats
This commit is contained in:
@@ -116,6 +116,7 @@ import {
|
||||
} from '../../../../src/terminal/terminal-accessory-layout'
|
||||
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
|
||||
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
|
||||
import { createTerminalAccessoryRepeatController } from '../../../../src/terminal/terminal-accessory-repeat'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
@@ -2947,14 +2948,14 @@ export default function SessionScreen() {
|
||||
|
||||
async function handleAccessoryKey(input: ReturnType<typeof createTerminalLiveAccessoryInput>) {
|
||||
if (!client || !activeHandle || !canSend) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
const targetHandle = activeHandle
|
||||
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
|
||||
if (accessoryCommit.kind !== 'allow-raw') {
|
||||
return
|
||||
return accessoryCommit.kind === 'handled'
|
||||
}
|
||||
await sendTerminalLiveAccessoryRawBytes({
|
||||
return sendTerminalLiveAccessoryRawBytes({
|
||||
client: clientRef.current,
|
||||
targetHandle,
|
||||
activeHandle: activeHandleRef.current,
|
||||
@@ -3347,32 +3348,22 @@ export default function SessionScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive).
|
||||
const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const accessoryRepeatRef = useRef(
|
||||
createTerminalAccessoryRepeatController<ReturnType<typeof createTerminalLiveAccessoryInput>>()
|
||||
)
|
||||
// Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal.
|
||||
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
|
||||
handleAccessoryKeyRef.current = handleAccessoryKey
|
||||
const stopAccessoryRepeat = useCallback(() => {
|
||||
if (repeatTimeoutRef.current) {
|
||||
clearTimeout(repeatTimeoutRef.current)
|
||||
repeatTimeoutRef.current = null
|
||||
}
|
||||
if (repeatIntervalRef.current) {
|
||||
clearInterval(repeatIntervalRef.current)
|
||||
repeatIntervalRef.current = null
|
||||
}
|
||||
accessoryRepeatRef.current.stop()
|
||||
}, [])
|
||||
const startAccessoryRepeat = useCallback(
|
||||
(input: ReturnType<typeof createTerminalLiveAccessoryInput>) => {
|
||||
stopAccessoryRepeat()
|
||||
repeatTimeoutRef.current = setTimeout(() => {
|
||||
repeatIntervalRef.current = setInterval(() => {
|
||||
void handleAccessoryKeyRef.current(input)
|
||||
}, 45)
|
||||
}, 400)
|
||||
accessoryRepeatRef.current.start(input, (nextInput) =>
|
||||
handleAccessoryKeyRef.current(nextInput)
|
||||
)
|
||||
},
|
||||
[stopAccessoryRepeat]
|
||||
[]
|
||||
)
|
||||
const setMobileSessionRootRef = useCallback(
|
||||
(node: View | null): void => {
|
||||
@@ -4773,7 +4764,6 @@ export default function SessionScreen() {
|
||||
return
|
||||
}
|
||||
const input = createTerminalLiveAccessoryInput(key)
|
||||
void handleAccessoryKey(input)
|
||||
startAccessoryRepeat(input)
|
||||
}}
|
||||
onPressOut={() => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createTerminalAccessoryRepeatController,
|
||||
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
|
||||
TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
|
||||
} from './terminal-accessory-repeat'
|
||||
|
||||
function deferred() {
|
||||
let resolve: () => void = () => {}
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('terminal accessory repeat', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('keeps one send in flight and preserves repeat order under host latency', async () => {
|
||||
vi.useFakeTimers()
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
const sent: string[] = []
|
||||
const send = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return (sent.length === 1 ? first.promise : second.promise).then(() => true)
|
||||
})
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
expect(sent).toEqual(['down'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_DELAY_MS + 500)
|
||||
expect(sent).toEqual(['down'])
|
||||
|
||||
first.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(sent).toEqual(['down', 'down'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS + 500)
|
||||
expect(sent).toEqual(['down', 'down'])
|
||||
|
||||
second.resolve()
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
|
||||
expect(sent).toEqual(['down', 'down', 'down'])
|
||||
})
|
||||
|
||||
it('does not schedule another repeat after release while a send is pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred()
|
||||
const send = vi.fn(() => pending.promise.then(() => true))
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
repeat.stop()
|
||||
pending.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops repeating when the transport rejects a send', async () => {
|
||||
vi.useFakeTimers()
|
||||
const send = vi.fn(() => Promise.reject(new Error('disconnected')))
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops repeating when the send is dropped before reaching the transport', async () => {
|
||||
vi.useFakeTimers()
|
||||
const send = vi.fn(async () => false)
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400
|
||||
export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45
|
||||
|
||||
type TerminalAccessoryRepeatSender<TInput> = (input: TInput) => Promise<boolean>
|
||||
|
||||
export function createTerminalAccessoryRepeatController<TInput>() {
|
||||
let generation = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const stop = () => {
|
||||
generation += 1
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const start = (input: TInput, send: TerminalAccessoryRepeatSender<TInput>) => {
|
||||
stop()
|
||||
const activeGeneration = generation
|
||||
const pressedAt = Date.now()
|
||||
|
||||
const schedule = (delayMs: number) => {
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
if (generation !== activeGeneration) {
|
||||
return
|
||||
}
|
||||
void send(input).then(
|
||||
(sent) => {
|
||||
if (sent && generation === activeGeneration) {
|
||||
schedule(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
|
||||
}
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
void send(input).then(
|
||||
(sent) => {
|
||||
if (sent && generation === activeGeneration) {
|
||||
schedule(
|
||||
Math.min(
|
||||
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
|
||||
Math.max(0, TERMINAL_ACCESSORY_REPEAT_DELAY_MS - (Date.now() - pressedAt))
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
|
||||
return { start, stop }
|
||||
}
|
||||
@@ -20,7 +20,7 @@ describe('terminal live accessory raw send', () => {
|
||||
it('sends raw bytes now-or-never with the device presence tag', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(true)
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
@@ -33,7 +33,9 @@ describe('terminal live accessory raw send', () => {
|
||||
it('drops the bytes instead of sending while disconnected', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -41,16 +43,16 @@ describe('terminal live accessory raw send', () => {
|
||||
it('drops the bytes when the terminal selection went stale mid-flush', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows a rejected send so accessory taps never surface transport errors', async () => {
|
||||
it('reports a rejected send without surfacing transport errors', async () => {
|
||||
const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send')))
|
||||
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
|
||||
).resolves.toBeUndefined()
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ type TerminalLiveAccessoryRawSendArgs = {
|
||||
|
||||
export async function sendTerminalLiveAccessoryRawBytes(
|
||||
args: TerminalLiveAccessoryRawSendArgs
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
// Why: async IME flushing can outlive the original terminal selection.
|
||||
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
|
||||
targetHandle: args.targetHandle,
|
||||
@@ -23,9 +23,9 @@ export async function sendTerminalLiveAccessoryRawBytes(
|
||||
activeSessionTabType: args.activeSessionTabType
|
||||
})
|
||||
if (!args.client || !rawSendTarget || args.connState !== 'connected') {
|
||||
return
|
||||
return false
|
||||
}
|
||||
await args.client
|
||||
return args.client
|
||||
.sendRequest(
|
||||
'terminal.send',
|
||||
buildTerminalSendParams({
|
||||
@@ -37,7 +37,7 @@ export async function sendTerminalLiveAccessoryRawBytes(
|
||||
TERMINAL_INPUT_SEND_OPTIONS
|
||||
)
|
||||
.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user