diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts index 6448cc3911d..ef3fed61b14 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts @@ -12,6 +12,7 @@ import { resolveStructuredAgentSessionAdoptionForCreate } from './structured-agent-session-create-adoption' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' @@ -279,6 +280,49 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca await this.ensureStructuredAgentSessionHost() await this.refreshMobileSessionPtyRecords() await getStructuredAgentSessionHost()?.reconcileRestartLeases() + // Why started here, and why not awaited: a restored chat pane subscribes as soon as its tab + // model hydrates, and a read refused for want of a readable session is what it paints as a + // failure — so readability must not wait for the first tab inventory, which itself waits on + // the client's whole terminal-restoration chain. Journal parsing still stays off the + // terminal-safety fence this method fences: the sweep runs alongside terminal restoration, + // and the tab projection awaits the same latched sweep. + void this.restoreReadableStructuredSessions().catch((error: unknown) => { + console.warn('[agent-session] startup readable restore failed', error) + }) + } + + /** Once per process: the startup sweep that opens every persisted chat's journal for reading. */ + protected restoreReadableStructuredSessions(): Promise { + this.structuredReadableSessionRestorePromise ??= + this.restoreReadableStructuredSessionsOnce().catch((error) => { + this.structuredReadableSessionRestorePromise = null + throw error + }) + return this.structuredReadableSessionRestorePromise + } + + private async restoreReadableStructuredSessionsOnce(): Promise { + const host = getStructuredAgentSessionHost() + if (!host) { + return + } + await host.restoreReadableSessions(this.selectStartupReadableStructuredSessionIds(host)) + } + + /** The persisted visible-tab index when the store keeps one, else every chat the saved + * workspace session still shows a tab for. */ + private selectStartupReadableStructuredSessionIds( + host: NonNullable> + ): readonly string[] { + const persistedVisibleIndex = + typeof host.getPersistedVisibleSessionTabIndex === 'function' + ? host.getPersistedVisibleSessionTabIndex() + : { present: false, sessionIds: [] } + return persistedVisibleIndex.present + ? persistedVisibleIndex.sessionIds + : collectSavedStructuredAgentSessionIds( + this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null + ) } protected hasPersistedStructuredAgentSessionStore(): boolean { diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index f641b7bb036..d3930357568 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -4,8 +4,6 @@ import { OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript } from './orca-r import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' -import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { RuntimeMobileSessionAgentTab, RuntimeMobileSessionTabsSnapshot, @@ -49,17 +47,9 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu protected async restoreStructuredAgentSessionTabsOnce(): Promise { await this.prepareStructuredAgentSessionStartupRestoration() + // The sweep startup restoration began; projection needs every persisted chat readable first. + await this.restoreReadableStructuredSessions() const host = getStructuredAgentSessionHost() - const persistedVisibleIndex = - typeof host?.getPersistedVisibleSessionTabIndex === 'function' - ? host.getPersistedVisibleSessionTabIndex() - : { present: false, sessionIds: [] } - const profileIds = collectSavedStructuredAgentSessionIds( - this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null - ) - await host?.restoreReadableSessions( - persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds - ) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index a93250f5d51..71030cbfd7a 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -122,6 +122,7 @@ export class OrcaRuntimeWithRuntimeId { protected structuredAgentSessionTabRestorePromise: Promise | null = null protected structuredAgentSessionStartupRestorePromise: Promise | null = null + protected structuredReadableSessionRestorePromise: Promise | null = null protected mobileSessionTabsChangeSequence = 0 diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index d6ec1e22782..5a9cd608a9b 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -27,12 +27,13 @@ describe('structured session cold restoration', () => { expect(reconcileRestartLeases).not.toHaveBeenCalled() }) - it('keeps historical journal parsing outside the terminal-safety fence', async () => { + it('starts the readable sweep at startup but keeps journal parsing off the terminal-safety fence', async () => { const runtime = new OrcaRuntimeService() const refresh = vi.fn(async () => new Set()) const ensureHost = vi.fn(async () => undefined) const reconcileRestartLeases = vi.fn(async () => undefined) - const restoreReadableSessions = vi.fn(async () => undefined) + // Never settles: the fence must open without waiting on a single journal. + const restoreReadableSessions = vi.fn(() => new Promise(() => undefined)) const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean refreshMobileSessionPtyRecords(): Promise | null> @@ -48,7 +49,10 @@ describe('structured session cold restoration', () => { expect(ensureHost).toHaveBeenCalledOnce() expect(refresh).toHaveBeenCalledOnce() expect(reconcileRestartLeases).toHaveBeenCalledOnce() - expect(restoreReadableSessions).not.toHaveBeenCalled() + expect(restoreReadableSessions).toHaveBeenCalledOnce() + expect(reconcileRestartLeases.mock.invocationCallOrder[0]).toBeLessThan( + restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity + ) }) it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index 5e5be1b6b3b..f5cc24ed149 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -248,6 +248,20 @@ describe('structured agent-session read transport unattached refusals', () => { } }) + it('reports a stream failure payload by its message, not as [object Object]', async () => { + vi.useFakeTimers() + try { + const applyError = vi.fn() + const transport = startWithHydration(async () => undefined, applyError) + await flushPromises() + attempts.at(-1)?.onError({ code: 'runtime_error', message: 'journal unreadable' }) + expect(applyError).toHaveBeenCalledWith('journal unreadable') + transport.dispose() + } finally { + vi.useRealTimers() + } + }) + it('surfaces an unattached refusal that outlives the grace window', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts index f4310cec890..e86f9fd5552 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts @@ -8,6 +8,20 @@ import { import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' +/** A stream delivers its failure as the raw RPC error payload, whose `String()` is `[object Object]`. */ +function describeReadFailure(error: unknown): string { + if (error instanceof Error) { + return String(error) + } + if (typeof error === 'object' && error !== null && 'message' in error) { + const { message } = error + if (typeof message === 'string' && message.length > 0) { + return message + } + } + return String(error) +} + function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: () => void }) { let timer: ReturnType | null = null return { @@ -79,13 +93,13 @@ export function startStructuredAgentSessionReadTransport(args: { const reportReadFailure = (error: unknown): void => { if (!isUnattachedAgentSessionReadRefusal(error)) { clearUnattachedReadGrace() - args.applyError(String(error)) + args.applyError(describeReadFailure(error)) return } const now = Date.now() unattachedSince ??= now if (now - unattachedSince >= AGENT_SESSION_UNATTACHED_READ_GRACE_MS) { - args.applyError(String(error)) + args.applyError(describeReadFailure(error)) } } const captureHistoryReadGuard = (): (() => boolean) => { diff --git a/src/renderer/src/components/terminal-cold-activation.ts b/src/renderer/src/components/terminal-cold-activation.ts index 8b6e96467ee..7b8ee127be2 100644 --- a/src/renderer/src/components/terminal-cold-activation.ts +++ b/src/renderer/src/components/terminal-cold-activation.ts @@ -1,11 +1,12 @@ import { useAppStore } from '../store' import { + canAdmitTerminalTabsForStartup, canDeferColdActivationTabsForHost, - canMountTerminalWorkspaceForStartup, planColdActivationTabDeferral, pruneClosedBackgroundMountTabs, revealActivationDeferredTabs } from './terminal/background-terminal-worktree-mount' +import { holdTerminalTabsForStartup } from './terminal/startup-terminal-tab-hold' import { hasRegisteredRuntimeTerminalTab } from '../runtime/sync-runtime-graph' import { anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout } from './terminal/split-group-mount' import { isParkRestorableTerminalPty } from './terminal-pane/terminal-hidden-view-parking' @@ -40,14 +41,28 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio workspaceSurfaceIds, workspaceSurfaceIdSet } = controller + // Why the surface mounts on the tab model alone: the hydrated tabs, groups, and layout are + // everything the tab strip and the chat, browser, and editor panes need. Only terminal + // panes wait, held below, for startup restoration to publish PTY ownership — gating the + // whole surface on that chain left a restored session blank until its last step. if ( renderedActiveWorktreeId && - canMountTerminalWorkspaceForStartup({ + !canAdmitTerminalTabsForStartup({ workspaceSessionReady, hydrationSucceeded, startupWorktreeRefreshCompleted }) ) { + // Why null: the gate opening must run the activation plan, which replaces the hold. + lastActivationWorktreeIdRef.current = null + holdTerminalTabsForStartup( + backgroundMountTabIdsByWorktreeRef.current, + activationDeferredMountTabIdsByWorktreeRef.current, + mountedWorktreeIdsRef.current, + renderedActiveWorktreeId + ) + mountedWorktreeIdsRef.current.add(renderedActiveWorktreeId) + } else if (renderedActiveWorktreeId) { const worktreeTabs = tabsByWorktree[renderedActiveWorktreeId] ?? [] const coldActivationDeferralEnabled = terminalParkingEnabled && terminalTitleSnapshotAuthorityEnabled diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts index 0978393bba7..117f049978e 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts @@ -11,7 +11,7 @@ import { addBackgroundMountedTerminalWorktree, applyBackgroundMountTabRestriction, canDeferColdActivationTabsForHost, - canMountTerminalWorkspaceForStartup, + canAdmitTerminalTabsForStartup, collectDeferredMountTabIds, hasRequestedBackgroundTerminalWorktreeMount, planColdActivationTabDeferral, @@ -23,24 +23,24 @@ import { shouldMountBackgroundWorktreeTab } from './background-terminal-worktree-mount' -describe('terminal workspace startup mount gate', () => { +describe('startup terminal tab admission gate', () => { it('waits for hydration unless startup entered degraded mode', () => { expect( - canMountTerminalWorkspaceForStartup({ + canAdmitTerminalTabsForStartup({ workspaceSessionReady: true, hydrationSucceeded: false, startupWorktreeRefreshCompleted: false }) ).toBe(false) expect( - canMountTerminalWorkspaceForStartup({ + canAdmitTerminalTabsForStartup({ workspaceSessionReady: true, hydrationSucceeded: true, startupWorktreeRefreshCompleted: false }) ).toBe(true) expect( - canMountTerminalWorkspaceForStartup({ + canAdmitTerminalTabsForStartup({ workspaceSessionReady: true, hydrationSucceeded: false, startupWorktreeRefreshCompleted: true diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts index 8f8bf4461b0..4f4214e07fc 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts @@ -150,7 +150,14 @@ export function shouldMountBackgroundWorktreeTab( // working set (and later tab switches) ends up exactly where it was before. export const COLD_ACTIVATION_TAB_DEFER_THRESHOLD = 0 -export function canMountTerminalWorkspaceForStartup(args: { +/** + * Whether restored terminal tabs may mount their panes. Why this fences panes and not the + * workspace surface: a pane binds a PTY on mount, and until startup restoration has fenced + * the daemon provider, reconnected SSH targets, and published PTY ownership, that bind would + * attach to a not-yet-restored session or spawn a duplicate shell. The tab strip and the + * chat, browser, and editor panes bind nothing, so they mount from the hydrated tab model. + */ +export function canAdmitTerminalTabsForStartup(args: { workspaceSessionReady: boolean hydrationSucceeded: boolean startupWorktreeRefreshCompleted: boolean diff --git a/src/renderer/src/components/terminal/startup-terminal-tab-hold.test.ts b/src/renderer/src/components/terminal/startup-terminal-tab-hold.test.ts new file mode 100644 index 00000000000..0b3f8b26532 --- /dev/null +++ b/src/renderer/src/components/terminal/startup-terminal-tab-hold.test.ts @@ -0,0 +1,272 @@ +// @vitest-environment happy-dom + +/** + * Defect under test: the restored workspace surface used to wait for the whole + * startup chain (SSH reconnect, PTY reconnect, legacy worker recovery) before it + * mounted, so a session whose tab model had been in the store for seconds painted + * nothing at all. Only terminal panes need that chain — they bind a PTY on mount. + * The surface now mounts on the tab model, and terminal tabs are held unadmitted + * until the gate opens and the activation plan replaces the hold. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, renderHook } from '@testing-library/react' +import { useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { applyTerminalColdActivation } from '../terminal-cold-activation' +import { useActivationDeferredTabAdmission } from './use-activation-deferred-tab-admission' +import { + pruneClosedBackgroundMountTabs, + revealActivationDeferredTabs, + shouldMountBackgroundWorktreeTab +} from './background-terminal-worktree-mount' +import { holdTerminalTabsForStartup } from './startup-terminal-tab-hold' +import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TerminalParkingFoundation } from '../use-terminal-parking-foundation' + +const WORKTREE_ID = 'repo::/worktree' +const OTHER_WORKTREE_ID = 'repo::/other-worktree' +const TAB_1 = 'tab-1' +const TAB_2 = 'tab-2' +const GROUP_ID = 'group-1' +const SURFACE_IDS = [WORKTREE_ID, OTHER_WORKTREE_ID] + +const initialState = useAppStore.getInitialState() +const originalRequestIdle = globalThis.requestIdleCallback +const originalCancelIdle = globalThis.cancelIdleCallback + +function terminalTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: `${worktreeId}@@${id}`, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function leafLayout(worktreeId: string): { + groups: TabGroup[] + layout: TabGroupLayoutNode +} { + return { + groups: [ + { id: GROUP_ID, worktreeId, activeTabId: TAB_1, tabOrder: [TAB_1, TAB_2], recentTabIds: [] } + ], + layout: { type: 'leaf', groupId: GROUP_ID } + } +} + +type HarnessProps = { worktreeId: string | null; gateOpen: boolean } + +/** Mirrors use-terminal-controller.ts: cold activation during render, then admission. */ +function useStartupHoldHarness(props: HarnessProps) { + const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) + const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map>()) + const lastActivationWorktreeIdRef = useRef(null) + const mountedWorktreeIdsRef = useRef(new Set()) + const activationDeferralPlanRevisionRef = useRef(0) + const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) + const restored = leafLayout(props.worktreeId ?? WORKTREE_ID) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: applyTerminalColdActivation and the admission hook read only the fields listed here; the rest of the foundation is render machinery this harness never exercises. + const foundation = { + activationDeferralPlanRevisionRef, + activationDeferredMountTabIdsByWorktreeRef, + activeGroupIdByWorktree: props.worktreeId ? { [props.worktreeId]: GROUP_ID } : {}, + activeTabId: TAB_1, + activeTabIdByWorktree: props.worktreeId ? { [props.worktreeId]: TAB_1 } : {}, + activeWorktreeDeferralHostId: 'local', + activityTerminalPortals: [], + backgroundMountRevision, + backgroundMountTabIdsByWorktreeRef, + groupsByWorktree: props.worktreeId ? { [props.worktreeId]: restored.groups } : {}, + hydrationSucceeded: props.gateOpen, + lastActivationWorktreeIdRef, + layoutByWorktree: props.worktreeId ? { [props.worktreeId]: restored.layout } : {}, + mountedWorktreeIdsRef, + pairedRuntimeParkingEnvironmentIds: new Set(), + pendingStartupByTabId: {}, + renderedActiveWorktreeId: props.worktreeId, + setBackgroundMountRevision, + startupWorktreeRefreshCompleted: props.gateOpen, + tabsByWorktree: useAppStore.getState().tabsByWorktree, + terminalParkingEnabled: true, + terminalTitleSnapshotAuthorityEnabled: true, + workspaceSessionReady: props.gateOpen, + workspaceSurfaceIds: SURFACE_IDS, + workspaceSurfaceIdSet: new Set(SURFACE_IDS) + } as unknown as TerminalParkingFoundation + const coldActivation = Object.assign(foundation, applyTerminalColdActivation(foundation)) + useActivationDeferredTabAdmission(coldActivation) + return { + activationDeferredMountTabIdsByWorktreeRef, + anyMountedWorktreeHasLayout: coldActivation.anyMountedWorktreeHasLayout, + backgroundMountTabIdsByWorktreeRef, + mountedWorktreeIdsRef + } +} + +function admits( + restrictions: Map>, + worktreeId: string, + tabId: string +): boolean { + return shouldMountBackgroundWorktreeTab(restrictions.get(worktreeId) ?? null, tabId) +} + +describe('startup terminal tab hold', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + useAppStore.setState({ + tabsByWorktree: { + [WORKTREE_ID]: [terminalTab(TAB_1, WORKTREE_ID), terminalTab(TAB_2, WORKTREE_ID)], + [OTHER_WORKTREE_ID]: [terminalTab(TAB_1, OTHER_WORKTREE_ID)] + } + }) + // Deterministic drain: force scheduleActivationDeferredAdmission onto timers. + // @ts-expect-error -- exercising the no-requestIdleCallback environment + globalThis.requestIdleCallback = undefined + // @ts-expect-error -- exercising the no-requestIdleCallback environment + globalThis.cancelIdleCallback = undefined + vi.useFakeTimers() + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + globalThis.requestIdleCallback = originalRequestIdle + globalThis.cancelIdleCallback = originalCancelIdle + useAppStore.setState(initialState, true) + }) + + it('mounts the restored surface before the gate opens and holds every terminal tab', () => { + const { result, rerender } = renderHook(useStartupHoldHarness, { + initialProps: { worktreeId: WORKTREE_ID, gateOpen: false } + }) + const restrictions = result.current.backgroundMountTabIdsByWorktreeRef.current + + // The surface mounts from the tab model alone... + expect(result.current.mountedWorktreeIdsRef.current.has(WORKTREE_ID)).toBe(true) + expect(result.current.anyMountedWorktreeHasLayout).toBe(true) + // ...while no terminal pane may bind a PTY yet, and none is idle-admission work. + expect(admits(restrictions, WORKTREE_ID, TAB_1)).toBe(false) + expect(admits(restrictions, WORKTREE_ID, TAB_2)).toBe(false) + expect(result.current.activationDeferredMountTabIdsByWorktreeRef.current.has(WORKTREE_ID)).toBe( + false + ) + act(() => { + vi.advanceTimersByTime(1_000) + }) + expect(admits(restrictions, WORKTREE_ID, TAB_1)).toBe(false) + + // The gate opening runs the activation plan, which replaces the hold. + rerender({ worktreeId: WORKTREE_ID, gateOpen: true }) + expect(result.current.mountedWorktreeIdsRef.current.has(WORKTREE_ID)).toBe(true) + expect(admits(restrictions, WORKTREE_ID, TAB_1)).toBe(true) + expect(admits(restrictions, WORKTREE_ID, TAB_2)).toBe(true) + }) + + it('returns a workspace switched away from mid-startup to the unmounted world', () => { + const { result, rerender } = renderHook(useStartupHoldHarness, { + initialProps: { worktreeId: WORKTREE_ID, gateOpen: false } + }) + expect(result.current.mountedWorktreeIdsRef.current.has(WORKTREE_ID)).toBe(true) + + rerender({ worktreeId: OTHER_WORKTREE_ID, gateOpen: false }) + const restrictions = result.current.backgroundMountTabIdsByWorktreeRef.current + expect(result.current.mountedWorktreeIdsRef.current.has(WORKTREE_ID)).toBe(false) + expect(restrictions.has(WORKTREE_ID)).toBe(false) + expect(result.current.mountedWorktreeIdsRef.current.has(OTHER_WORKTREE_ID)).toBe(true) + expect(admits(restrictions, OTHER_WORKTREE_ID, TAB_1)).toBe(false) + + rerender({ worktreeId: OTHER_WORKTREE_ID, gateOpen: true }) + expect(admits(restrictions, OTHER_WORKTREE_ID, TAB_1)).toBe(true) + }) + + it('does not mount a surface with no active workspace', () => { + const { result } = renderHook(useStartupHoldHarness, { + initialProps: { worktreeId: null, gateOpen: false } + }) + expect(result.current.mountedWorktreeIdsRef.current.size).toBe(0) + expect(result.current.backgroundMountTabIdsByWorktreeRef.current.size).toBe(0) + }) +}) + +describe('holdTerminalTabsForStartup', () => { + it('admits no terminal tab of a worktree that has not mounted yet', () => { + const restrictions = new Map>() + const deferred = new Map>() + const mounted = new Set() + + holdTerminalTabsForStartup(restrictions, deferred, mounted, 'wt-active') + + expect(restrictions.get('wt-active')).toEqual(new Set()) + expect(shouldMountBackgroundWorktreeTab(restrictions.get('wt-active') ?? null, 'tab-1')).toBe( + false + ) + expect(deferred.has('wt-active')).toBe(false) + }) + + it('keeps a targeted background mount that landed first and never narrows a full mount', () => { + const restrictions = new Map>([['wt-active', new Set(['tab-1'])]]) + const deferred = new Map>() + const mounted = new Set(['wt-full']) + + holdTerminalTabsForStartup(restrictions, deferred, mounted, 'wt-active') + expect(restrictions.get('wt-active')).toEqual(new Set(['tab-1'])) + + holdTerminalTabsForStartup(restrictions, deferred, mounted, 'wt-full') + expect(restrictions.has('wt-full')).toBe(false) + }) + + it('survives prune and reveal passes untouched', () => { + const restrictions = new Map>() + const deferred = new Map>() + const mounted = new Set() + holdTerminalTabsForStartup(restrictions, deferred, mounted, 'wt-active') + mounted.add('wt-active') + + expect( + pruneClosedBackgroundMountTabs( + restrictions, + mounted, + { 'wt-active': [{ id: 'tab-1' }] }, + deferred + ) + ).toBe(false) + revealActivationDeferredTabs({ + restrictions, + deferredMountTabIdsByWorktree: deferred, + worktreeId: 'wt-active', + allTabIds: ['tab-1'], + immediateTabIds: new Set(['tab-1']) + }) + expect(restrictions.get('wt-active')).toEqual(new Set()) + expect(mounted.has('wt-active')).toBe(true) + }) + + it('releases holds on other worktrees but leaves targeted and activation restrictions alone', () => { + const restrictions = new Map>([ + ['wt-previous', new Set()], + ['wt-targeted', new Set(['tab-wake'])], + ['wt-activation', new Set()] + ]) + const deferred = new Map>([ + ['wt-activation', new Set(['tab-deferred'])] + ]) + const mounted = new Set(['wt-previous', 'wt-targeted', 'wt-activation']) + + holdTerminalTabsForStartup(restrictions, deferred, mounted, 'wt-active') + + expect(restrictions.has('wt-previous')).toBe(false) + expect(mounted.has('wt-previous')).toBe(false) + expect(restrictions.get('wt-targeted')).toEqual(new Set(['tab-wake'])) + expect(restrictions.get('wt-activation')).toEqual(new Set()) + expect(mounted.has('wt-targeted')).toBe(true) + expect(mounted.has('wt-activation')).toBe(true) + expect(restrictions.get('wt-active')).toEqual(new Set()) + }) +}) diff --git a/src/renderer/src/components/terminal/startup-terminal-tab-hold.ts b/src/renderer/src/components/terminal/startup-terminal-tab-hold.ts new file mode 100644 index 00000000000..a09b12ac26f --- /dev/null +++ b/src/renderer/src/components/terminal/startup-terminal-tab-hold.ts @@ -0,0 +1,44 @@ +import { applyBackgroundMountTabRestriction } from './background-terminal-worktree-mount' + +/** A hold is the one restriction shape nothing else produces: no admitted tabs and no + * deferral entry. Prune drops an emptied targeted restriction outright, and an activation + * restriction emptied by closes keeps its deferral entry. */ +function isStartupTerminalTabHold( + restrictions: ReadonlyMap>, + deferredMountTabIdsByWorktree: ReadonlyMap>, + worktreeId: string +): boolean { + return restrictions.get(worktreeId)?.size === 0 && !deferredMountTabIdsByWorktree.has(worktreeId) +} + +/** + * Keeps every terminal tab of the active worktree unmounted while startup restoration is + * still publishing PTY ownership, so the workspace surface can mount from the hydrated tab + * model without a pane binding a PTY early. Must run before the worktree joins + * `mountedWorktreeIds`, like any restriction. + * + * Why an empty admitted set and no deferral entry: reveal and idle admission act only on + * worktrees with a deferral entry, and prune leaves an unchanged restriction alone, so the + * hold survives every render pass until the startup gate opens and the activation plan + * replaces it. A targeted background mount that lands meanwhile widens the hold to its + * tabs, exactly as it would widen any restriction. Holds on other worktrees are released — + * nothing was mounted under them — so a workspace switched away from mid-startup returns + * to the unmounted world where parked watchers cover it. + */ +export function holdTerminalTabsForStartup( + restrictions: Map>, + deferredMountTabIdsByWorktree: ReadonlyMap>, + mountedWorktreeIds: Set, + worktreeId: string +): void { + for (const [heldWorktreeId] of restrictions) { + if ( + heldWorktreeId !== worktreeId && + isStartupTerminalTabHold(restrictions, deferredMountTabIdsByWorktree, heldWorktreeId) + ) { + restrictions.delete(heldWorktreeId) + mountedWorktreeIds.delete(heldWorktreeId) + } + } + applyBackgroundMountTabRestriction(restrictions, mountedWorktreeIds, worktreeId, []) +}