Files
orca/mobile/src/session/mobile-native-chat-terminal-stream.ts
T
Brennan Benson 6b84e33251 fix(mobile): trade a lease-only stream for output when leaving a chat tab (#14179)
* fix(mobile): trade a lease-only stream for output when leaving a chat tab

Tapping a terminal tab from a native-chat tab left the terminal blank. The
route subscribes the incoming handle synchronously in switchTab, while the
coverage it reads still describes the chat tab being left, so the handle gets
a `mobileInputLeaseOnly` subscribe — the host answers `subscribed` and nothing
else, no scrollback and no data frames. Input kept working because it rides a
separate terminal.send RPC.

The reconciler then cleared its covered marker (the active handle changed), so
the stream was active and uncovered — which its state machine could not tell
apart from a healthy one, because `streamActive` conflated the two. It settled
on 'none' and nothing else repaired it: the route's web-ready path bails on any
live subscription. The tab stayed blank until app restart.

Track which handles hold a lease-only subscribe and thread it into the
reconciler as `streamIsLeaseOnly`, so an uncovered handle holding one resumes
into a full stream. The covered branch is untouched, so the input lease that
keeps the chat composer from locking forever (#10681) still survives.

* fix(mobile): clarify stream reconciliation ownership

* fix(mobile): keep stream reconciliation checks clean
2026-08-13 00:07:28 -07:00

62 lines
2.4 KiB
TypeScript

export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'rearm' | 'none'
/** Decides whether the active mobile terminal stream should run while native chat
* covers its WebView. Resume is allowed only once the mounted WebView is ready. */
export function resolveMobileNativeChatTerminalStreamAction(args: {
showNativeChat: boolean
activeHandle: string | null
activeTabType: string | null
streamActive: boolean
streamCovered: boolean
/** The active stream was opened as an input lease, so the host sends `subscribed`
* and nothing else — no scrollback, no data. Distinguishes a stream that renders
* from one that only holds the input floor. */
streamIsLeaseOnly: boolean
webViewReady: boolean
}): MobileNativeChatTerminalStreamAction {
if (!args.activeHandle || args.activeTabType !== 'terminal') {
return 'none'
}
if (args.showNativeChat) {
if (!args.streamCovered) {
return 'pause'
}
// Why: the covered stream IS the input lease. Anything that tore it down
// (terminal.list churn, a client swap, an `end` frame) would otherwise leave
// the composer locked forever — nothing else re-subscribes a covered handle.
return args.streamActive ? 'none' : 'rearm'
}
// Why `streamIsLeaseOnly`: switching from a chat tab to a terminal tab subscribes the
// incoming handle before the route learns chat is gone, so it lands a lease-only stream
// on an uncovered handle. Without this input that is indistinguishable from a healthy
// stream and the terminal renders blank until restart.
return (args.streamCovered || args.streamIsLeaseOnly || !args.streamActive) && args.webViewReady
? 'resume'
: 'none'
}
export function isTerminalCoveredByNativeChat(
showNativeChat: boolean,
activeHandle: string | null,
handle: string
): boolean {
return showNativeChat && activeHandle === handle
}
export function mobileNativeChatTerminalCapabilities(covered: boolean): {
terminalBinaryStream: 1
mobileInputLeaseOnly?: 1
} {
return covered
? { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 }
: { terminalBinaryStream: 1 }
}
// Why: a covered subscribe is only an input lease — carrying phone dims would make the host phone-fit a PTY native chat never renders.
export function mobileNativeChatSubscribeViewport(
covered: boolean,
viewport: { cols: number; rows: number } | null
): { cols: number; rows: number } | undefined {
return covered ? undefined : (viewport ?? undefined)
}