diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b2dab546bcd..2d9199a3f7e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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:` - // 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) { diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index b8db56adaa8..ff9b8203af2 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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 diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts index a46ed3eb633..d72692a1042 100644 --- a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -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 = {}): 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 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 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 }), diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index c8ae7dc5a5f..84e96ddd77f 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -17,8 +17,6 @@ function stubRuntime(overrides: Partial = {}): 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 diff --git a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts index 56a982b3bc9..17a2057fc5a 100644 --- a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts +++ b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts @@ -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:` 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' } ]) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index dd73096a225..e9d8641de9d 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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: 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) + } }) ) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts new file mode 100644 index 00000000000..1107f098207 --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -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 + +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() + }) +}) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.ts new file mode 100644 index 00000000000..ead0ade6b31 --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -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, + 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 +} diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.ts index a77ac6fd95d..cbfa46945b8 100644 --- a/src/renderer/src/lib/terminal-tab-for-pty-id.ts +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.ts @@ -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:` 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, worktreeId: string,