From 8200b46da8862cfd06fc37ea840add13bc035a8e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:32:43 -0700 Subject: [PATCH] fix(mobile): keep terminal input composable while the connection is cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #6713. While the socket was down every input control on the mobile session screen was hard-disabled by the single canSend gate — the keyboard would not even open, and everything typed during the outage was silently discarded. Split the gate: canCompose (local composing, survives an outage) vs canSend (needs the live socket). The buffered command box stays editable offline and holds the text; the send button, accessory keys, and live-input capture stay connection-gated; the live/buffered mode toggle stays tappable so live-mode users can reach the compose box. The return-key submit path holds composed text instead of firing a doomed RPC. Also reset the live-input mirror when the connection drops: bytes sent into a stalled link are lost but were recorded as delivered, so the first post-reconnect send replayed stale fragments or emitted phantom erases (observed as `YZZYecho CLEANLINE` corrupting the next command on device). --- .../app/h/[hostId]/session/[worktreeId].tsx | 26 ++-- .../terminal-input-connection-gate.test.ts | 115 ++++++++++++++++++ .../terminal-input-connection-gate.ts | 26 ++++ .../use-terminal-live-input-commit.test.ts | 54 +++++++- .../use-terminal-live-input-commit.ts | 11 ++ 5 files changed, 219 insertions(+), 13 deletions(-) create mode 100644 mobile/src/terminal/terminal-input-connection-gate.test.ts create mode 100644 mobile/src/terminal/terminal-input-connection-gate.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 7e4ac17933f..4191788f030 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -127,6 +127,7 @@ import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-sen import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply' import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' +import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate' import { getTerminalCommandKeyboardType, getTerminalLiveInputKeyboardType @@ -1058,18 +1059,18 @@ export default function SessionScreen() { activeHandleRef, activeSessionTabType: activeSessionTab?.type, activeSessionTabTypeRef, + connected: connState === 'connected', liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, sendLiveTerminalInputRef, setLiveInputCapture }) - const canSend = - connState === 'connected' && - activeHandle != null && - activeSessionTab?.type !== 'markdown' && - activeSessionTab?.type !== 'file' && - activeSessionTab?.type !== 'browser' + const { canCompose, canSend } = resolveMobileTerminalInputGate({ + connState, + activeHandle, + activeSessionTabType: activeSessionTab?.type + }) const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) // Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel. @@ -2991,7 +2992,8 @@ export default function SessionScreen() { }, [activeSessionTab, fileDocs, readFileTab]) async function handleSend() { - if (!client || !activeHandle || sendingRef.current) { + // Why: the return key still submits while offline; hold the composed text instead of firing a doomed RPC (#6713). + if (!client || !activeHandle || sendingRef.current || !canSend) { return } sendingRef.current = true @@ -4849,9 +4851,10 @@ export default function SessionScreen() { styles.accessoryKey, liveInputEnabled && styles.accessoryKeyActive, pressed && styles.accessoryKeyPressed, - !canSend && styles.accessoryKeyDisabled + !canCompose && styles.accessoryKeyDisabled ]} - disabled={!canSend} + // Why: offline, live mode is dead but the buffered box still composes — keep the escape hatch tappable (#6713). + disabled={!canCompose} onPress={toggleLiveInput} accessibilityLabel={ liveInputEnabled @@ -4864,7 +4867,7 @@ export default function SessionScreen() { color={ liveInputEnabled ? colors.bgBase - : canSend + : canCompose ? colors.textSecondary : colors.textMuted } @@ -5059,7 +5062,8 @@ export default function SessionScreen() { autocompleteEnabled )} returnKeyType="send" - editable={canSend} + // Why: composing is local — an outage must not lock the field or discard typed text (#6713). + editable={canCompose} onSubmitEditing={() => void handleSend()} /> { + it('Given a live connection on a terminal tab Then composing and sending are both allowed', () => { + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: 'terminal-a', + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: true, canSend: true }) + }) + + it('Given a cut connection Then composing stays available while sending is blocked', () => { + for (const connState of [ + 'connecting', + 'handshaking', + 'disconnected', + 'reconnecting', + 'auth-failed' + ] as const) { + expect( + resolveMobileTerminalInputGate({ + connState, + activeHandle: 'terminal-a', + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: true, canSend: false }) + } + }) + + it('Given a non-terminal tab or no handle Then neither composing nor sending is allowed', () => { + for (const activeSessionTabType of ['markdown', 'file', 'browser']) { + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: 'terminal-a', + activeSessionTabType + }) + ).toEqual({ canCompose: false, canSend: false }) + } + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: null, + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: false, canSend: false }) + }) + + it('Given a lagging tab list yielding no tab Then the gate treats the type as unknown, not non-terminal', () => { + expect( + resolveMobileTerminalInputGate({ + connState: 'disconnected', + activeHandle: 'terminal-a', + activeSessionTabType: undefined + }) + ).toEqual({ canCompose: true, canSend: false }) + }) +}) + +describe('session route offline-compose wiring', () => { + it('derives both gates from the shared resolver', () => { + expect(sessionRouteSource).toContain('resolveMobileTerminalInputGate({') + }) + + it('keeps the buffered command box editable offline while the live capture stays send-gated', () => { + const bufferedInput = routeSlice( + 'ref={commandInputRef}', + 'onSubmitEditing={() => void handleSend()}' + ) + expect(bufferedInput).toContain('editable={canCompose}') + + const liveCapture = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"') + expect(liveCapture).toContain('editable={canSend}') + }) + + it('keeps the send button connection-gated so held text cannot fire into a dead link', () => { + const sendButton = routeSlice('styles.sendButton,', 'accessibilityLabel="Send command"') + expect(sendButton).toContain('disabled={!canSend}') + }) + + it('holds composed text when the return key submits offline', () => { + const handleSend = routeSlice('async function handleSend()', 'sendingRef.current = true') + expect(handleSend).toContain('!canSend') + }) + + it('keeps the live/buffered mode toggle reachable offline', () => { + const modeToggle = routeSlice( + 'liveInputEnabled && styles.accessoryKeyActive', + 'onPress={toggleLiveInput}' + ) + expect(modeToggle).toContain('disabled={!canCompose}') + }) + + it('tells the live-input commit hook about connection loss so stale mirror state resets', () => { + const hookCall = routeSlice('useTerminalLiveInputCommit({', 'setLiveInputCapture') + expect(hookCall).toContain("connected: connState === 'connected'") + }) +}) diff --git a/mobile/src/terminal/terminal-input-connection-gate.ts b/mobile/src/terminal/terminal-input-connection-gate.ts new file mode 100644 index 00000000000..c8d4da30138 --- /dev/null +++ b/mobile/src/terminal/terminal-input-connection-gate.ts @@ -0,0 +1,26 @@ +import type { ConnectionState } from '../transport/types' + +type MobileTerminalInputGateOptions = { + readonly connState: ConnectionState + readonly activeHandle: string | null + readonly activeSessionTabType: string | null | undefined +} + +type MobileTerminalInputGate = { + // Why: composing is local — it must survive an outage so typed text is held, not discarded (#6713). + readonly canCompose: boolean + readonly canSend: boolean +} + +export function resolveMobileTerminalInputGate({ + connState, + activeHandle, + activeSessionTabType +}: MobileTerminalInputGateOptions): MobileTerminalInputGate { + const canCompose = + activeHandle != null && + activeSessionTabType !== 'markdown' && + activeSessionTabType !== 'file' && + activeSessionTabType !== 'browser' + return { canCompose, canSend: canCompose && connState === 'connected' } +} diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index b759e30d615..45d0d1d26d5 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -11,6 +11,8 @@ type TerminalLiveInputCommitHarness = { readonly handlers: ReturnType> readonly sent: readonly string[] readonly setActiveSessionTabType: (next: string | undefined) => void + readonly setConnected: (next: boolean) => void + readonly setSendResult: (next: boolean) => void readonly unmount: () => void } @@ -46,15 +48,17 @@ function createTerminalLiveInputCommitHarness({ current: new Set([activeHandle]) } const sent: string[] = [] + let currentSendResult = sendResult const sendLiveTerminalInputRef: RefObject = { current: async (_handle, bytes) => { sent.push(bytes) - return sendResult + return currentSendResult } } // The hook keeps live-input state in refs, so a change handler alone never - // re-renders; only a prop change (this variable) re-runs the pending-clear effect. + // re-renders; only a prop change (these variables) re-runs the clear effects. let currentActiveSessionTabType: string | undefined = 'terminal' + let currentConnected = true let handlers: ReturnType> | null = null let renderer: ReactTestRenderer | null = null @@ -64,6 +68,7 @@ function createTerminalLiveInputCommitHarness({ activeHandleRef, activeSessionTabType: currentActiveSessionTabType, activeSessionTabTypeRef, + connected: currentConnected, liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, @@ -98,6 +103,15 @@ function createTerminalLiveInputCommitHarness({ renderer?.update(createElement(Harness)) }) }, + setConnected: (next: boolean): void => { + currentConnected = next + act(() => { + renderer?.update(createElement(Harness)) + }) + }, + setSendResult: (next: boolean): void => { + currentSendResult = next + }, unmount: () => { act(() => renderer?.unmount()) } @@ -340,4 +354,40 @@ describe('terminal live input commit hook', () => { // Then: pending was dropped, so submit sends only the carriage return await vi.waitFor(() => expect(sent).toEqual(['\r'])) }) + + it('Given bytes lost in a silent stall When the disconnect is detected Then the first post-recovery send carries no stale fragment or phantom erases', async () => { + // Given: a stalled link — the mirror sends but the PTY never accepts (#6713 second defect) + const { captures, handlers, sent, setConnected, setSendResult } = + createTerminalLiveInputCommitHarness({ sendResult: false }) + handlers.handleLiveInputChange('XYZZY') + await vi.waitFor(() => expect(sent).toEqual(['XYZZY'])) + + // When: the outage is finally detected, then the link recovers + setConnected(false) + setSendResult(true) + setConnected(true) + + // Then: the capture was wiped, and fresh typing sends verbatim bytes — not + // 'XYZZY…' replayed and not DELs erasing PTY chars that never arrived + expect(captures.at(-1)).toBe('') + const sentBeforeRecovery = sent.length + handlers.handleLiveInputChange('echo CLEANLINE') + await vi.waitFor(() => expect(sent.slice(sentBeforeRecovery)).toEqual(['echo CLEANLINE'])) + }) + + it('Given a held syllable during an outage When the disconnect is detected Then the settle timer cannot commit it later', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent, setConnected } = createTerminalLiveInputCommitHarness({ + sendResult: false + }) + handlers.handleLiveInputChange('한') + + // When + setConnected(false) + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS) + + // Then: the outage cleared the held text before the timer could send it + expect(sent).toEqual([]) + }) }) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 9c707dfbb31..fbfb892969c 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -22,6 +22,7 @@ type TerminalLiveInputCommitOptions = { readonly activeHandleRef: RefObject readonly activeSessionTabType: TTabType | null | undefined readonly activeSessionTabTypeRef: RefObject + readonly connected: boolean readonly liveInputRef: RefObject readonly liveInputTerminalHandles: ReadonlySet readonly liveInputTerminalHandlesRef: RefObject> @@ -45,6 +46,7 @@ export function useTerminalLiveInputCommit({ activeHandleRef, activeSessionTabType, activeSessionTabTypeRef, + connected, liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, @@ -68,6 +70,15 @@ export function useTerminalLiveInputCommit({ setLiveInputCapture }) + useEffect(() => { + // Why: sends during an outage are silently lost, so the mirror's "already on the PTY" + // record is unknowable after a cut — stale state would replay fragments or emit phantom + // erases into the first post-reconnect send. Reset while down; the PTY echo is truth. + if (!connected) { + clearPendingLiveInputCommit() + } + }, [connected, clearPendingLiveInputCommit]) + useEffect(() => { const pendingHandle = pendingLiveInputHandleRef.current if (!pendingHandle) {