fix(mobile): background-mount never-mounted terminals on subscribe so they don't render blank (STA-1840)

A mobile terminal.subscribe to a tab the desktop never mounted this session —
a workspace the desktop isn't currently showing, a cold-activation-deferred tab
(#8597), or a cold-parked tab — has no attached PTY, so the runtime has no
headless emulator and the initial snapshot is empty. The mobile terminal then
renders blank (repro: on the phone, scroll to an old workspace whose terminals
the desktop unmounted; they stay blank).

When a mobile subscribe hits an empty snapshot (or resolves no PTY), the runtime
now asks the renderer to background-mount that tab so the PTY attaches and the
live data stream — already subscribed on that path — delivers its output.

A never-mounted workspace has no renderer-graph leaf, so its terminals are
surfaced to mobile via synthetic pty:<ptyId> handles that carry no real UI
tabId. The runtime passes the ptyId through the mount request and the renderer
resolves the owning tab from the persisted tab model (resolveTerminalTabIdForPtyId),
falling back to a whole-worktree background mount if it cannot. The request is
idempotent for already-mounted tabs and never unmounts a live pane, so the worst
case is the same blank as before — no regression.

Validated on an iOS simulator with a two-workspace desktop: opening the
workspace the desktop was not showing rendered a fully blank terminal without
the fix and the live shell prompt with it.

Tests: runtime mount-request gating (real-tab / pty-form / no-ptyId / unknown),
subscribe-handler empty-snapshot-vs-content, and the ptyId->tabId resolver; all
608 RPC method tests pass.
This commit is contained in:
Brennan Benson
2026-07-14 20:54:41 -07:00
parent 1b3f281ff5
commit 321e2cf0ba
11 changed files with 437 additions and 0 deletions
+37
View File
@@ -18722,6 +18722,43 @@ 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.
requestRendererTerminalTabMount(handle: string): void {
const record = this.handles.get(handle)
if (!record?.worktreeId) {
return
}
const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId
const ptyId = record.ptyId ?? undefined
if (!tabId && !ptyId) {
return
}
try {
this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', {
worktreeId: record.worktreeId,
...(tabId ? { tabId } : {}),
...(ptyId ? { ptyId } : {})
})
} catch {
// No authoritative window (shutdown/headless): the subscribe keeps its
// existing empty-snapshot fallback.
}
}
// Why: a leaf appears in the graph before its PTY spawns. If we issue a
// handle while ptyId is null, the next graph sync after PTY spawn will
// change ptyId and invalidate the handle. Wait for a connected PTY so
+15
View File
@@ -2362,6 +2362,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// 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).
runtime.requestRendererTerminalTabMount(params.terminal)
try {
const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal)
leaf = { ptyId }
@@ -2772,6 +2777,16 @@ 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) {
runtime.requestRendererTerminalTabMount(params.terminal)
}
let initialOutputOverflowed = false
if (pendingOutputOverflowed) {
pendingOutput.splice(0)
@@ -0,0 +1,138 @@
/**
* 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.
*/
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from './dispatcher'
import type { RpcRequest } from './core'
import type { OrcaRuntimeService } from '../orca-runtime'
import { TERMINAL_METHODS } from './methods/terminal'
import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
return {
getRuntimeId: () => 'test-runtime',
registerRemoteTerminalViewSubscriber: () => () => {},
requestRendererTerminalTabMount: () => {},
...overrides
} as OrcaRuntimeService
}
const makeRequest = (method: string, params?: unknown): RpcRequest => ({
id: 'req-1',
authToken: 'tok',
method,
params
})
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.
const cleanups = new Map<string, () => void>()
const requestRendererTerminalTabMount = vi.fn()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
requestRendererTerminalTabMount,
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),
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
isTerminalAlternateScreen: vi.fn().mockReturnValue(false),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
cleanups.get(id)?.()
cleanups.delete(id)
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'phone-1', type: 'mobile' },
capabilities: { terminalBinaryStream: 1 }
}),
vi.fn(),
{
connectionId: 'conn-phone',
sendBinary: vi.fn(),
registerBinaryStreamHandler: vi.fn(() => vi.fn())
}
)
await vi.waitFor(() =>
expect(requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1')
)
runtime.cleanupSubscription('terminal-1:phone-1')
await dispatchPromise
})
it('does not request a renderer tab mount when the mobile snapshot has content', async () => {
const cleanups = new Map<string, () => void>()
const requestRendererTerminalTabMount = vi.fn()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
requestRendererTerminalTabMount,
handleMobileSubscribe: vi.fn().mockResolvedValue(true),
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 }),
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
isTerminalAlternateScreen: vi.fn().mockReturnValue(false),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
cleanups.get(id)?.()
cleanups.delete(id)
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'phone-1', type: 'mobile' },
capabilities: { terminalBinaryStream: 1 }
}),
vi.fn(),
{
connectionId: 'conn-phone',
sendBinary: vi.fn(),
registerBinaryStreamHandler: vi.fn(() => vi.fn())
}
)
await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled())
expect(requestRendererTerminalTabMount).not.toHaveBeenCalled()
runtime.cleanupSubscription('terminal-1:phone-1')
await dispatchPromise
})
})
@@ -17,6 +17,9 @@ 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
}
@@ -0,0 +1,131 @@
/**
* 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.
*/
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
type HandleSeed = {
handle: string
worktreeId: string
tabId: string
ptyId: string | null
}
type RuntimeInternals = {
handles: Map<
string,
HandleSeed & {
runtimeId: string
rendererGraphEpoch: number
leafId: string
ptyGeneration: number
}
>
getAuthoritativeWindow: () => {
webContents: { send: (channel: string, payload: unknown) => void }
}
}
function seedRuntime(seeds: HandleSeed[]): {
runtime: OrcaRuntimeService
send: ReturnType<typeof vi.fn>
} {
const runtime = new OrcaRuntimeService()
const internals = runtime as unknown as RuntimeInternals
for (const seed of seeds) {
internals.handles.set(seed.handle, {
...seed,
runtimeId: 'rt-test',
rendererGraphEpoch: 1,
leafId: 'leaf-1',
ptyGeneration: 1
})
}
const send = vi.fn()
internals.getAuthoritativeWindow = () => ({ webContents: { send } })
return { runtime, send }
}
describe('requestRendererTerminalTabMount', () => {
it('requests a tab mount by tabId for a real-tab handle awaiting its PTY (null-leaf blank path)', () => {
const { runtime, send } = seedRuntime([
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null }
])
runtime.requestRendererTerminalTabMount('h1')
expect(send).toHaveBeenCalledWith('terminal:requestTabMount', {
worktreeId: 'wt-1',
tabId: 'tab-1'
})
})
it('requests a tab mount by tabId even when a real-tab handle carries a ptyId', () => {
const { runtime, send } = seedRuntime([
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: 'wt-1@@abc' }
])
runtime.requestRendererTerminalTabMount('h1')
expect(send).toHaveBeenCalledWith('terminal:requestTabMount', {
worktreeId: 'wt-1',
tabId: 'tab-1',
ptyId: 'wt-1@@abc'
})
})
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).
const { runtime, send } = seedRuntime([
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:wt-1@@abc', ptyId: 'wt-1@@abc' }
])
runtime.requestRendererTerminalTabMount('h1')
expect(send).toHaveBeenCalledWith('terminal:requestTabMount', {
worktreeId: 'wt-1',
ptyId: 'wt-1@@abc'
})
})
it('does not request a mount when a pty-form handle has no ptyId', () => {
const { runtime, send } = seedRuntime([
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:abc', ptyId: null }
])
runtime.requestRendererTerminalTabMount('h1')
expect(send).not.toHaveBeenCalled()
})
it('does not request a mount for an unknown handle', () => {
const { runtime, send } = seedRuntime([])
runtime.requestRendererTerminalTabMount('missing')
expect(send).not.toHaveBeenCalled()
})
it('swallows window lookup failures so subscribe keeps its fallback', () => {
const { runtime } = seedRuntime([
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null }
])
const internals = runtime as unknown as RuntimeInternals
internals.getAuthoritativeWindow = () => {
throw new Error('no window')
}
expect(() => runtime.requestRendererTerminalTabMount('h1')).not.toThrow()
})
})
+3
View File
@@ -2821,6 +2821,9 @@ export type PreloadApi = {
onRequestTerminalCreate: (
callback: (data: RuntimeTerminalCreateRequestPayload) => void
) => () => void
onRequestTerminalTabMount: (
callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void
) => () => void
replyTerminalCreate: (reply: {
requestId: string
tabId?: string
+10
View File
@@ -3495,6 +3495,16 @@ const api = {
ipcRenderer.on('terminal:requestTabCreate', listener)
return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener)
},
onRequestTerminalTabMount: (
callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { worktreeId: string; tabId?: string; ptyId?: string }
) => callback(data)
ipcRenderer.on('terminal:requestTabMount', listener)
return () => ipcRenderer.removeListener('terminal:requestTabMount', listener)
},
replyTerminalCreate: (reply: {
requestId: string
tabId?: string
+24
View File
@@ -14,6 +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 type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts'
@@ -1666,6 +1667,29 @@ 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.
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: CLI-driven terminal creation sends a request and waits for the
// tabId reply so it can resolve a handle the caller can use immediately.
// This mirrors the browser's onRequestTabCreate/replyTabCreate pattern.
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id'
import type { AppState } from '@/store/types'
type ResolverState = Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>
function state(partial: {
tabs?: Record<string, { id: string; ptyId?: string | null }[]>
layouts?: Record<string, { ptyIdsByLeafId?: Record<string, string> }>
}): ResolverState {
return {
tabsByWorktree: (partial.tabs ?? {}) as unknown as AppState['tabsByWorktree'],
terminalLayoutsByTabId: (partial.layouts ?? {}) as unknown as AppState['terminalLayoutsByTabId']
}
}
describe('resolveTerminalTabIdForPtyId', () => {
it('matches a tab by its own ptyId', () => {
const s = state({
tabs: {
wt: [
{ id: 'tab-a', ptyId: 'wt@@1' },
{ id: 'tab-b', ptyId: 'wt@@2' }
]
}
})
expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@2')).toBe('tab-b')
})
it('matches a tab by a split leaf ptyId in its saved layout', () => {
const s = state({
tabs: { wt: [{ id: 'tab-a', ptyId: null }] },
layouts: { 'tab-a': { ptyIdsByLeafId: { leaf1: 'wt@@1', leaf2: 'wt@@9' } } }
})
expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@9')).toBe('tab-a')
})
it('returns null when no tab owns the ptyId', () => {
const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } })
expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@nope')).toBeNull()
})
it('returns null for an unknown worktree', () => {
const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } })
expect(resolveTerminalTabIdForPtyId(s, 'other', 'wt@@1')).toBeNull()
})
})
@@ -0,0 +1,28 @@
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.
*/
export function resolveTerminalTabIdForPtyId(
state: Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>,
worktreeId: string,
ptyId: string
): string | null {
const tabs = state.tabsByWorktree[worktreeId] ?? []
for (const tab of tabs) {
if (tab.ptyId === ptyId) {
return tab.id
}
const ptyIdsByLeafId = state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId
if (ptyIdsByLeafId && Object.values(ptyIdsByLeafId).includes(ptyId)) {
return tab.id
}
}
return null
}
+1
View File
@@ -2368,6 +2368,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onActivateWorktree: () => noopUnsubscribe,
onCreateTerminal: () => noopUnsubscribe,
onRequestTerminalCreate: () => noopUnsubscribe,
onRequestTerminalTabMount: () => noopUnsubscribe,
replyTerminalCreate: () => {},
onSplitTerminal: () => noopUnsubscribe,
onRenameTerminal: () => noopUnsubscribe,