From ffb5182f0fe8d1f2d55a6dea0a10785b315b3dfc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:18:46 -0700 Subject: [PATCH] fix(mobile): keep accessory repeats on the pressed terminal --- .../app/h/[hostId]/session/[worktreeId].tsx | 24 +++++++++++++------ .../terminal-accessory-repeat.test.ts | 18 ++++++++++++++ .../src/terminal/terminal-accessory-repeat.ts | 13 ++++++++++ .../terminal-live-accessory-raw-send.test.ts | 22 ++++++++++++++++- .../terminal-live-accessory-raw-send.ts | 6 ++--- 5 files changed, 71 insertions(+), 12 deletions(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index fdb47b4dc1f..af9859caddf 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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) { - if (!client || !activeHandle || !canSend) { + async function handleAccessoryKey( + input: ReturnType, + 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>() ) - // 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) => { - accessoryRepeatRef.current.start(input, (nextInput) => - handleAccessoryKeyRef.current(nextInput) + accessoryRepeatRef.current.start( + input, + createTerminalAccessoryRepeatSender( + activeHandleRef.current, + () => activeHandleRef.current, + (nextInput, targetHandle) => handleAccessoryKeyRef.current(nextInput, targetHandle) + ) ) }, [] diff --git a/mobile/src/terminal/terminal-accessory-repeat.test.ts b/mobile/src/terminal/terminal-accessory-repeat.test.ts index fa132823b3e..eac77cf5439 100644 --- a/mobile/src/terminal/terminal-accessory-repeat.test.ts +++ b/mobile/src/terminal/terminal-accessory-repeat.test.ts @@ -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') + }) }) diff --git a/mobile/src/terminal/terminal-accessory-repeat.ts b/mobile/src/terminal/terminal-accessory-repeat.ts index 8b4a4d41d61..8dc571ffe89 100644 --- a/mobile/src/terminal/terminal-accessory-repeat.ts +++ b/mobile/src/terminal/terminal-accessory-repeat.ts @@ -3,6 +3,19 @@ export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45 type TerminalAccessoryRepeatSender = (input: TInput) => Promise +export function createTerminalAccessoryRepeatSender( + targetHandle: string | null, + getActiveHandle: () => string | null, + sendToTerminal: (input: TInput, targetHandle: string) => Promise +): TerminalAccessoryRepeatSender { + return (input) => { + if (!targetHandle || getActiveHandle() !== targetHandle) { + return Promise.resolve(false) + } + return sendToTerminal(input, targetHandle) + } +} + export function createTerminalAccessoryRepeatController() { let generation = 0 let timer: ReturnType | null = null diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts index 4a9f03edc32..4ecab7dd10c 100644 --- a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts @@ -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 = Promise.resolve({ ok: true })) { +function captureClient( + result: Promise = 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, 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) + }) }) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.ts index 4e6efc08abd..3824d750792 100644 --- a/mobile/src/terminal/terminal-live-accessory-raw-send.ts +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.ts @@ -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) }