fix(mobile): keep accessory repeats on the pressed terminal

This commit is contained in:
Brennan Benson
2026-08-27 13:18:46 -07:00
parent a42735e3c7
commit ffb5182f0f
5 changed files with 71 additions and 12 deletions
+17 -7
View File
@@ -117,7 +117,10 @@ 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 {
createTerminalAccessoryRepeatController,
createTerminalAccessoryRepeatSender
} from '../../../../src/terminal/terminal-accessory-repeat'
import {
clearTerminalLiveInputFocusTimer,
isTerminalLiveInputWithinByteLimit,
@@ -2994,11 +2997,13 @@ export default function SessionScreen() {
}
}
async function handleAccessoryKey(input: ReturnType<typeof createTerminalLiveAccessoryInput>) {
if (!client || !activeHandle || !canSend) {
async function handleAccessoryKey(
input: ReturnType<typeof createTerminalLiveAccessoryInput>,
targetHandle = activeHandleRef.current
) {
if (!client || !targetHandle || targetHandle !== activeHandleRef.current || !canSend) {
return false
}
const targetHandle = activeHandle
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
if (accessoryCommit.kind !== 'allow-raw') {
return accessoryCommit.kind === 'handled'
@@ -3403,7 +3408,7 @@ export default function SessionScreen() {
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.
// Why: the current callback observes reconnect state while the sender pins the press to its original terminal.
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
handleAccessoryKeyRef.current = handleAccessoryKey
const stopAccessoryRepeat = useCallback(() => {
@@ -3411,8 +3416,13 @@ export default function SessionScreen() {
}, [])
const startAccessoryRepeat = useCallback(
(input: ReturnType<typeof createTerminalLiveAccessoryInput>) => {
accessoryRepeatRef.current.start(input, (nextInput) =>
handleAccessoryKeyRef.current(nextInput)
accessoryRepeatRef.current.start(
input,
createTerminalAccessoryRepeatSender(
activeHandleRef.current,
() => activeHandleRef.current,
(nextInput, targetHandle) => handleAccessoryKeyRef.current(nextInput, targetHandle)
)
)
},
[]
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createTerminalAccessoryRepeatController,
createTerminalAccessoryRepeatSender,
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
} from './terminal-accessory-repeat'
@@ -80,4 +81,21 @@ describe('terminal accessory repeat', () => {
expect(send).toHaveBeenCalledTimes(1)
})
it('stops sending when the terminal active at press time is no longer active', async () => {
let activeTerminal = 'terminal-a'
const sendToTerminal = vi.fn(async () => true)
const send = createTerminalAccessoryRepeatSender(
activeTerminal,
() => activeTerminal,
sendToTerminal
)
await expect(send('down')).resolves.toBe(true)
activeTerminal = 'terminal-b'
await expect(send('down')).resolves.toBe(false)
expect(sendToTerminal).toHaveBeenCalledTimes(1)
expect(sendToTerminal).toHaveBeenCalledWith('down', 'terminal-a')
})
})
@@ -3,6 +3,19 @@ export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45
type TerminalAccessoryRepeatSender<TInput> = (input: TInput) => Promise<boolean>
export function createTerminalAccessoryRepeatSender<TInput>(
targetHandle: string | null,
getActiveHandle: () => string | null,
sendToTerminal: (input: TInput, targetHandle: string) => Promise<boolean>
): TerminalAccessoryRepeatSender<TInput> {
return (input) => {
if (!targetHandle || getActiveHandle() !== targetHandle) {
return Promise.resolve(false)
}
return sendToTerminal(input, targetHandle)
}
}
export function createTerminalAccessoryRepeatController<TInput>() {
let generation = 0
let timer: ReturnType<typeof setTimeout> | null = null
@@ -1,8 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
function captureClient(result: Promise<unknown> = Promise.resolve({ ok: true })) {
function captureClient(
result: Promise<unknown> = Promise.resolve({
id: 'send',
ok: true,
result: { send: { handle: 'terminal-a', accepted: true, bytesWritten: 1 } },
_meta: { runtimeId: 'test-runtime' }
})
) {
const sendRequest = vi.fn(() => result)
return { client: { sendRequest } as unknown as Pick<RpcClient, 'sendRequest'>, sendRequest }
}
@@ -55,4 +63,16 @@ describe('terminal live accessory raw send', () => {
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
})
it('reports a fulfilled RPC failure as a failed send', async () => {
const response: RpcResponse = {
id: 'send',
ok: false,
error: { code: 'terminal_error', message: 'failed' },
_meta: { runtimeId: 'test-runtime' }
}
const { client } = captureClient(Promise.resolve(response))
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
})
})
@@ -1,4 +1,5 @@
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -36,8 +37,5 @@ export async function sendTerminalLiveAccessoryRawBytes(
}),
TERMINAL_INPUT_SEND_OPTIONS
)
.then(
() => true,
() => false
)
.then(isTerminalSendRpcAccepted, () => false)
}