fix(mobile): keep terminal input composable while the connection is cut

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).
This commit is contained in:
Brennan Benson
2026-07-29 18:32:43 -07:00
parent 24a2accc3c
commit 8200b46da8
5 changed files with 219 additions and 13 deletions
+15 -11
View File
@@ -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<boolean | null>(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()}
/>
<MobileTerminalInputActions
@@ -0,0 +1,115 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { resolveMobileTerminalInputGate } from './terminal-input-connection-gate'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
function routeSlice(anchorStart: string, anchorEnd: string): string {
const start = sessionRouteSource.indexOf(anchorStart)
expect(start).toBeGreaterThanOrEqual(0)
const end = sessionRouteSource.indexOf(anchorEnd, start)
expect(end).toBeGreaterThan(start)
return sessionRouteSource.slice(start, end + anchorEnd.length)
}
describe('terminal input connection gate', () => {
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'")
})
})
@@ -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' }
}
@@ -11,6 +11,8 @@ type TerminalLiveInputCommitHarness = {
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
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<TerminalLiveInputSender> = {
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<typeof useTerminalLiveInputCommit<string>> | 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([])
})
})
@@ -22,6 +22,7 @@ type TerminalLiveInputCommitOptions<TTabType extends string> = {
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabType: TTabType | null | undefined
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly connected: boolean
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
@@ -45,6 +46,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
activeHandleRef,
activeSessionTabType,
activeSessionTabTypeRef,
connected,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
@@ -68,6 +70,15 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
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) {