mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user