fix(mobile): harden blank terminal recovery mounts

This commit is contained in:
Brennan Benson
2026-07-14 22:02:09 -07:00
parent 8082166a5b
commit 730d6e1387
9 changed files with 106 additions and 89 deletions
+6 -15
View File
@@ -6721,6 +6721,10 @@ export class OrcaRuntimeService {
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
}
hasHeadlessTerminalState(ptyId: string): boolean {
return this.headlessTerminals.has(ptyId)
}
serializeMainTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {}
@@ -18722,21 +18726,8 @@ export class OrcaRuntimeService {
})
}
// Why: a mobile subscribe can target a tab whose pane the desktop never
// mounted this session — a cold-activation-deferred tab (#8597), a cold-parked
// tab, or any tab in a workspace the desktop isn't currently showing. With no
// mounted pane the PTY is never attached, so the runtime has no headless
// emulator and the mobile snapshot is empty → the terminal renders blank
// (STA-1840). Ask the renderer to background-mount that tab so it attaches/
// cold-restores; the live data stream the subscribe already holds then
// delivers output. Idempotent — a request for an already-mounted tab is a
// no-op in the renderer.
//
// Why pass ptyId: a workspace the renderer never mounted has no renderer-graph
// leaves, so its terminals are surfaced to mobile via synthetic `pty:<ptyId>`
// handles that carry no real UI tabId. The renderer resolves the owning tab
// from the ptyId; when the handle already names a real tab we send that
// directly.
// Why: never-mounted tabs have no attached PTY or mobile snapshot; synthetic
// handles need the ptyId so the renderer can mount the exact owning tab.
requestRendererTerminalTabMount(handle: string): void {
const record = this.handles.get(handle)
if (!record?.worktreeId) {
+11 -12
View File
@@ -2356,17 +2356,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
let leaf = runtime.resolveLeafForHandle(params.terminal)
const isMobile = params.client?.type === 'mobile'
const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary)
let rendererMountRequested = false
// Why: the left pane's PTY spawns asynchronously after the tab is created.
// Mobile clients that subscribe before the PTY is ready would get a bare
// scrollback+end with no live stream or phone-fit. Wait for the PTY so
// the subscribe can proceed normally.
if (!leaf?.ptyId && isMobile) {
// Why: a tab the desktop never mounted this session (cold-activation
// deferral / cold-park / a workspace the desktop isn't showing) has no
// leaf in the runtime graph, so no PTY is attached to wait for. Ask the
// renderer to background-mount it so one attaches (#8597, STA-1840).
// Why: a never-mounted tab has no graph leaf to await; mounting the
// exact tab lets its PTY attach without activating the worktree.
runtime.requestRendererTerminalTabMount(params.terminal)
rendererMountRequested = true
try {
const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal)
leaf = { ptyId }
@@ -2777,14 +2777,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (closed) {
return
}
// Why: the handle resolved a ptyId (from the persisted layout graph) but
// the runtime has no headless emulator for it — the desktop never mounted
// this tab this session, so nothing ever attached the PTY to feed one.
// The initial snapshot is therefore empty and the mobile terminal renders
// blank (STA-1840). Ask the renderer to background-mount the tab; that
// attaches the PTY and the live data stream (already subscribed above)
// then delivers the output. Idempotent for already-mounted tabs.
if (isMobile && !serialized?.data && read.tail.length === 0) {
// Why: missing model state—not snapshot text—is the signal that this
// PTY may never have attached; avoid remounting legitimate blank panes.
if (
isMobile &&
!rendererMountRequested &&
runtime.hasHeadlessTerminalState?.(ptyId) === false
) {
runtime.requestRendererTerminalTabMount(params.terminal)
}
let initialOutputOverflowed = false
@@ -1,11 +1,4 @@
/**
* STA-1840: a mobile terminal.subscribe to a tab the desktop never mounted this
* session resolves a ptyId (from the persisted layout graph) but the runtime
* has no headless emulator for it, so the snapshot is empty and the terminal
* renders blank. The subscribe now asks the renderer to background-mount the tab
* when the snapshot comes back empty. These tests pin that the mount request
* fires on an empty snapshot and stays quiet when the snapshot has content.
*/
/** STA-1840 regression: missing mobile terminal models request an exact renderer tab mount. */
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from './dispatcher'
import type { RpcRequest } from './core'
@@ -18,6 +11,7 @@ function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeSe
getRuntimeId: () => 'test-runtime',
registerRemoteTerminalViewSubscriber: () => () => {},
requestRendererTerminalTabMount: () => {},
hasHeadlessTerminalState: () => true,
...overrides
} as OrcaRuntimeService
}
@@ -30,22 +24,21 @@ const makeRequest = (method: string, params?: unknown): RpcRequest => ({
})
describe('terminal.subscribe blank-tab background mount', () => {
it('requests a renderer tab mount when a mobile subscribe resolves a ptyId but the snapshot is empty', async () => {
// Why: STA-1840 blank path — the handle resolves a ptyId but the desktop
// never mounted the tab this session, so there is no headless emulator and
// serialize is empty. The subscribe must ask the renderer to background-mount
// the tab so the PTY attaches and the live stream fills it.
it('requests a renderer tab mount when a mobile subscribe has no headless model', async () => {
// Why: stale preview text must not hide the missing live model/attachment.
const cleanups = new Map<string, () => void>()
const requestRendererTerminalTabMount = vi.fn()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
requestRendererTerminalTabMount,
hasHeadlessTerminalState: vi.fn().mockReturnValue(false),
handleMobileSubscribe: vi.fn().mockResolvedValue(true),
handleMobileUnsubscribe: vi.fn(),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
// Empty snapshot: no headless emulator for a never-attached PTY.
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
readTerminal: vi.fn().mockResolvedValue({ tail: ['stale preview'], truncated: false }),
serializeTerminalBuffer: vi
.fn()
.mockResolvedValue({ data: 'stale snapshot', cols: 80, rows: 24, seq: 4 }),
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
@@ -85,7 +78,7 @@ describe('terminal.subscribe blank-tab background mount', () => {
await dispatchPromise
})
it('does not request a renderer tab mount when the mobile snapshot has content', async () => {
it('does not request a renderer tab mount when an attached terminal is legitimately blank', async () => {
const cleanups = new Map<string, () => void>()
const requestRendererTerminalTabMount = vi.fn()
const runtime = stubRuntime({
@@ -95,9 +88,7 @@ describe('terminal.subscribe blank-tab background mount', () => {
handleMobileUnsubscribe: vi.fn(),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi
.fn()
.mockResolvedValue({ data: 'live content', cols: 80, rows: 24, seq: 4 }),
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
@@ -17,8 +17,6 @@ function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeSe
// Why: subscribe streams register as remote view subscribers for Phase-5
// query-authority suppression (terminal-query-authority.md).
registerRemoteTerminalViewSubscriber: () => () => {},
// Why: a no-PTY / empty-snapshot mobile subscribe asks the renderer to
// background-mount the tab (STA-1840). No-op by default.
requestRendererTerminalTabMount: () => {},
...overrides
} as OrcaRuntimeService
@@ -1,14 +1,4 @@
/**
* Mobile blank-terminal regression (STA-1840 / #8597): a mobile terminal.subscribe
* to a tab the desktop never mounted this session (cold-activation deferral,
* cold-park, or a workspace the desktop isn't showing) has no attached PTY, so
* the runtime has no headless emulator and the snapshot is empty → the terminal
* renders blank. The runtime now asks the renderer to background-mount that tab
* (terminal:requestTabMount) so the PTY attaches and streams. These tests pin
* the request's gating: it fires for a known handle whether or not the handle
* carries a ptyId (both blank paths), skips pty-form/unknown handles, and
* degrades safely when there is no window.
*/
/** STA-1840 regression: known blank-terminal handles request a bounded renderer mount. */
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
@@ -83,10 +73,7 @@ describe('requestRendererTerminalTabMount', () => {
})
it('requests a mount by ptyId for a synthetic pty-form handle (never-mounted workspace)', () => {
// Why: a workspace the renderer never mounted has no renderer-graph leaf, so
// its terminals surface via `pty:<ptyId>` handles with no real UI tabId. The
// mount must still fire — carried by the ptyId — or the never-mounted tab
// stays blank (STA-1840 regression the first fix attempt missed).
// Why: never-mounted workspaces expose only synthetic pty handles to mobile.
const { runtime, send } = seedRuntime([
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:wt-1@@abc', ptyId: 'wt-1@@abc' }
])
+13 -16
View File
@@ -14,7 +14,7 @@ import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping
import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel'
import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal'
import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount'
import { resolveTerminalTabIdForPtyId } from '@/lib/terminal-tab-for-pty-id'
import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount'
import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts'
@@ -1667,26 +1667,23 @@ export function useIpcEvents(): void {
)
)
// Why: a mobile subscribe against a terminal this renderer never mounted
// this session (cold-activation deferral / cold-park / a workspace the
// desktop isn't showing) has no attached PTY, so its snapshot is empty and
// the mobile terminal is blank. Background-mount that tab so the PTY attaches
// and streams (STA-1840). Idempotent for already-mounted tabs.
// Why: background-mounting a mobile-subscribed tab attaches a PTY that this
// renderer never mounted, without navigating the desktop (STA-1840).
unsubs.push(
window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => {
if (!worktreeId) {
return
}
// Why: a never-mounted workspace has no renderer-graph leaf, so the
// runtime only knows the ptyId (synthetic pty:<ptyId> handle) — resolve
// the owning UI tab from the persisted tab model. Fall back to a
// whole-worktree mount so the terminal still attaches if we can't.
const resolvedTabId =
tabId ??
(ptyId ? resolveTerminalTabIdForPtyId(useAppStore.getState(), worktreeId, ptyId) : null)
requestBackgroundTerminalWorktreeMount(
resolvedTabId ? { worktreeId, tabIds: [resolvedTabId] } : { worktreeId }
)
// Why: synthetic pty handles need persisted-tab resolution, but a miss
// must not mount every saved terminal in a large hidden worktree.
const mount = planMobileTerminalTabMount(useAppStore.getState(), {
worktreeId,
...(tabId ? { tabId } : {}),
...(ptyId ? { ptyId } : {})
})
if (mount) {
requestBackgroundTerminalWorktreeMount(mount)
}
})
)
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import type { AppState } from '@/store/types'
import { planMobileTerminalTabMount } from './mobile-terminal-tab-mount'
type PlannerState = Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>
function state(tabCount = 1): PlannerState {
return {
tabsByWorktree: {
wt: Array.from({ length: tabCount }, (_, index) => ({
id: `tab-${index}`,
ptyId: `wt@@${index}`
}))
} as unknown as AppState['tabsByWorktree'],
terminalLayoutsByTabId: {}
}
}
describe('planMobileTerminalTabMount', () => {
it('keeps real-tab requests targeted to exactly one tab', () => {
expect(planMobileTerminalTabMount(state(), { worktreeId: 'wt', tabId: 'tab-0' })).toEqual({
worktreeId: 'wt',
tabIds: ['tab-0']
})
})
it('resolves synthetic handles to exactly one owning tab at workspace scale', () => {
expect(planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@173' })).toEqual({
worktreeId: 'wt',
tabIds: ['tab-173']
})
})
it('does not mount the whole worktree when a stale pty id has no owner', () => {
expect(
planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@missing' })
).toBeNull()
})
})
@@ -0,0 +1,23 @@
import type { AppState } from '@/store/types'
import type { BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal'
import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id'
export type MobileTerminalTabMountRequest = {
worktreeId: string
tabId?: string
ptyId?: string
}
/** Why: exact-tab planning prevents a stale ptyId from mounting every saved xterm (#8597). */
export function planMobileTerminalTabMount(
state: Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>,
request: MobileTerminalTabMountRequest
): BackgroundMountTerminalWorktreeDetail | null {
if (!request.worktreeId) {
return null
}
const tabId =
request.tabId ??
(request.ptyId ? resolveTerminalTabIdForPtyId(state, request.worktreeId, request.ptyId) : null)
return tabId ? { worktreeId: request.worktreeId, tabIds: [tabId] } : null
}
@@ -1,14 +1,6 @@
import type { AppState } from '@/store/types'
/**
* Resolve the UI terminal tab id that owns a given ptyId within a worktree.
*
* Why: a workspace the renderer never mounted has no live pane and no
* renderer-graph leaf, so a mobile subscribe only knows the ptyId (surfaced via
* a synthetic `pty:<ptyId>` handle). To background-mount the pane so its PTY
* attaches (STA-1840), we map the ptyId back to the persisted tab — either the
* tab's own `ptyId` or a split leaf recorded in its saved layout.
*/
/** Resolve a synthetic mobile handle's ptyId through persisted tab and split bindings. */
export function resolveTerminalTabIdForPtyId(
state: Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>,
worktreeId: string,