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 ef3fed61b14..6aa64a8f590 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 @@ -13,6 +13,7 @@ import { } from './structured-agent-session-create-adoption' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' +import { isStructuredNativeChatEnabled } from './rpc/methods/structured-agent-session-policy' 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' @@ -285,28 +286,22 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca // 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 + // and the tab projection awaits the same sweep. Gated like that projection: with structured + // chat off, no surface can read a chat, so startup owes it no sweep. + if (isStructuredNativeChatEnabled(this)) { + void this.restoreReadableStructuredSessions().catch((error: unknown) => { + console.warn('[agent-session] startup readable restore failed', error) }) - return this.structuredReadableSessionRestorePromise + } } - private async restoreReadableStructuredSessionsOnce(): Promise { + /** The startup sweep that opens every persisted chat's journal for reading. The host latches it + * once per process, so the startup and projection callers share one run. */ + protected async restoreReadableStructuredSessions(): Promise { const host = getStructuredAgentSessionHost() - if (!host) { - return + if (host) { + await host.restoreReadableSessions(this.selectStartupReadableStructuredSessionIds(host)) } - await host.restoreReadableSessions(this.selectStartupReadableStructuredSessionIds(host)) } /** The persisted visible-tab index when the store keeps one, else every chat the saved diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 71030cbfd7a..a93250f5d51 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -122,7 +122,6 @@ 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 5a9cd608a9b..20781a8e45a 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -42,6 +42,10 @@ describe('structured session cold restoration', () => { internal.hasPersistedStructuredAgentSessionStore = () => true internal.refreshMobileSessionPtyRecords = refresh internal.ensureStructuredAgentSessionHost = ensureHost + vi.spyOn(runtime, 'getClientSettings').mockReturnValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup gate reads only experimentalStructuredNativeChat from client settings. + { experimentalStructuredNativeChat: true } as never + ) setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions } as never) await runtime.prepareStructuredAgentSessionStartupRestoration() @@ -55,6 +59,30 @@ describe('structured session cold restoration', () => { ) }) + it('starts no readable sweep at startup while structured chat is off', async () => { + const runtime = new OrcaRuntimeService() + const reconcileRestartLeases = vi.fn(async () => undefined) + const restoreReadableSessions = vi.fn(async () => undefined) + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => undefined + vi.spyOn(runtime, 'getClientSettings').mockReturnValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup gate reads only experimentalStructuredNativeChat from client settings. + { experimentalStructuredNativeChat: false } as never + ) + setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions } as never) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + + expect(reconcileRestartLeases).toHaveBeenCalledOnce() + expect(restoreReadableSessions).not.toHaveBeenCalled() + }) + it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => { const runtime = new OrcaRuntimeService() const hydrate = vi.fn() diff --git a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx index 5c3145360f6..cfe381ccce9 100644 --- a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx +++ b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx @@ -1,5 +1,6 @@ import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { WorktreeSplitSurface } from './TerminalWorktreeSplitSurface' +import { selectParkedEquivalentMountTabIds } from './terminal/startup-terminal-tab-hold' import type { TerminalController } from './use-terminal-controller' export function TerminalSplitWorkspaceSurfaces({ @@ -21,6 +22,7 @@ export function TerminalSplitWorkspaceSurfaces({ measurableBackgroundWorktreeIdsRef, mountedWorktreeIdsRef, renderedActiveWorktreeId, + startupTerminalTabHold, workspaceSurfaces } = controller // Why: this and TerminalSurface are both strict ancestors of every browser , so a @@ -69,9 +71,11 @@ export function TerminalSplitWorkspaceSurfaces({ backgroundMountTabIds={ backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null } - activationDeferredMountTabIds={ - activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null - } + activationDeferredMountTabIds={selectParkedEquivalentMountTabIds( + activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id), + startupTerminalTabHold, + workspace.id + )} /> ) })} diff --git a/src/renderer/src/components/terminal-cold-activation.ts b/src/renderer/src/components/terminal-cold-activation.ts index 7b8ee127be2..ac54b125afe 100644 --- a/src/renderer/src/components/terminal-cold-activation.ts +++ b/src/renderer/src/components/terminal-cold-activation.ts @@ -6,7 +6,10 @@ import { pruneClosedBackgroundMountTabs, revealActivationDeferredTabs } from './terminal/background-terminal-worktree-mount' -import { holdTerminalTabsForStartup } from './terminal/startup-terminal-tab-hold' +import { + holdTerminalTabsForStartup, + releaseStartupTerminalTabHold +} 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' @@ -33,6 +36,7 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio pairedRuntimeParkingEnvironmentIds, pendingStartupByTabId, renderedActiveWorktreeId, + startupTerminalTabHoldRef, startupWorktreeRefreshCompleted, tabsByWorktree, terminalParkingEnabled, @@ -45,23 +49,24 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio // 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 ( + const startupHeldWorktreeId = renderedActiveWorktreeId && !canAdmitTerminalTabsForStartup({ workspaceSessionReady, hydrationSucceeded, startupWorktreeRefreshCompleted }) - ) { + ? renderedActiveWorktreeId + : null + releaseStartupTerminalTabHold( + startupTerminalTabHoldRef, + backgroundMountTabIdsByWorktreeRef.current, + mountedWorktreeIdsRef.current, + startupHeldWorktreeId + ) + if (startupHeldWorktreeId) { // 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 = @@ -172,6 +177,15 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio tabsByWorktree, activationDeferredMountTabIdsByWorktreeRef.current ) + if (startupHeldWorktreeId) { + holdTerminalTabsForStartup( + startupTerminalTabHoldRef, + backgroundMountTabIdsByWorktreeRef.current, + mountedWorktreeIdsRef.current, + startupHeldWorktreeId, + (tabsByWorktree[startupHeldWorktreeId] ?? []).map((tab) => tab.id) + ) + } for (const id of mountedWorktreeIdsRef.current) { if (!workspaceSurfaceIdSet.has(id)) { mountedWorktreeIdsRef.current.delete(id) @@ -188,7 +202,8 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio ) return { anyMountedWorktreeHasLayout, - activationDeferralPlanRevision: activationDeferralPlanRevisionRef.current + activationDeferralPlanRevision: activationDeferralPlanRevisionRef.current, + startupTerminalTabHold: startupTerminalTabHoldRef.current } } diff --git a/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx index efcc2fb8d44..37b40a20053 100644 --- a/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx +++ b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx @@ -80,6 +80,7 @@ function renderWatcherEffects(overrides: Partial = {}): Promi pendingStartupByTabId: {}, // Another workspace is on screen, so the mounted one is hidden and parks. renderedActiveWorktreeId: 'repo-1::/worktree-9', + startupTerminalTabHold: null, tabsByWorktree: { [PARKED_WORKTREE_ID]: [ terminalTab('tab-parked', PARKED_WORKTREE_ID), @@ -127,6 +128,20 @@ describe('parked terminal watcher sync entries', () => { expect([...(parkedEntry?.parkedTabIds ?? [])]).toEqual(['tab-parked']) }) + it('watches the tabs a startup hold keeps unmounted on the visible workspace', async () => { + const heldWorktreeId = surfaceIds[9] + const tab = terminalTab('held-agent', heldWorktreeId, 'live-pty') + await renderWatcherEffects({ + mountedWorktreeIdsRef: { current: new Set([heldWorktreeId]) }, + startupTerminalTabHold: { worktreeId: heldWorktreeId, heldTabIds: new Set([tab.id]) }, + tabsByWorktree: { [heldWorktreeId]: [tab] } + }) + + const entry = lastSyncEntries().get(heldWorktreeId) + expect([...entry!.parkedTabIds]).toEqual([tab.id]) + expect([...entry!.restoreTitleOnStartTabIds!]).toEqual([tab.id]) + }) + it('does not allocate a parked-tab-id set per unmounted surface', async () => { await renderWatcherEffects() diff --git a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx index f93b06a3b05..d21b878c218 100644 --- a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx +++ b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx @@ -77,6 +77,7 @@ describe('workspace surface ids', () => { it('does not rebuild a surface-id array or set on a cold-activation render', () => { const ids = Array.from({ length: 423 }, (_, index) => `repo::/worktree-${index}`) const { surfaces, mapCalls } = countingSurfaces(ids) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: applyTerminalColdActivation reads only the fields listed here; the rest of the foundation is render machinery this test never exercises. const controller = { activationDeferralPlanRevisionRef: { current: 0 }, activationDeferredMountTabIdsByWorktreeRef: { current: new Map() }, @@ -89,6 +90,7 @@ describe('workspace surface ids', () => { groupsByWorktree: {}, hydrationSucceeded: false, lastActivationWorktreeIdRef: { current: null }, + startupTerminalTabHoldRef: { current: null }, layoutByWorktree: {}, mountedWorktreeIdsRef: { current: new Set(['repo::/worktree-0', 'repo::/gone']) }, pairedRuntimeParkingEnvironmentIds: new Set(), diff --git a/src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts b/src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts index 89737f42991..40ab1847a4e 100644 --- a/src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts +++ b/src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts @@ -31,6 +31,7 @@ import { import { capturedPanesByTabId } from '../terminal-pane/terminal-parked-watcher-registry' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TerminalParkingFoundation } from '../use-terminal-parking-foundation' +import type { StartupTerminalTabHold } from './startup-terminal-tab-hold' const WORKTREE_ID = 'repo::/worktree' const OTHER_WORKTREE_ID = 'repo::/other-worktree' @@ -82,9 +83,11 @@ function useStrandingHarness(props: HarnessProps) { const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map>()) const lastActivationWorktreeIdRef = useRef(null) + const startupTerminalTabHoldRef = useRef(null) const mountedWorktreeIdsRef = useRef(new Set()) const activationDeferralPlanRevisionRef = useRef(0) const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) + // 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, @@ -98,6 +101,7 @@ function useStrandingHarness(props: HarnessProps) { groupsByWorktree: {}, hydrationSucceeded: props.gateOpen, lastActivationWorktreeIdRef, + startupTerminalTabHoldRef, layoutByWorktree: {}, mountedWorktreeIdsRef, pairedRuntimeParkingEnvironmentIds: new Set(), diff --git a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx index ec897d479da..8d761110137 100644 --- a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx @@ -72,6 +72,7 @@ function Watcher({ restored = true, hydrated = false, worktreeId = 'wt-1' } = {} pairedRuntimeParkingEnvironmentIds: new Set(), pendingStartupByTabId: {}, renderedActiveWorktreeId: worktreeId, + startupTerminalTabHold: null, terminalParkingEnabled: false, terminalProviderSnapshotCapabilityRevision: 0, terminalSshParkingEnabled: false, 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 index 0b3f8b26532..d49d4a511cd 100644 --- a/src/renderer/src/components/terminal/startup-terminal-tab-hold.test.ts +++ b/src/renderer/src/components/terminal/startup-terminal-tab-hold.test.ts @@ -19,7 +19,12 @@ import { revealActivationDeferredTabs, shouldMountBackgroundWorktreeTab } from './background-terminal-worktree-mount' -import { holdTerminalTabsForStartup } from './startup-terminal-tab-hold' +import { + holdTerminalTabsForStartup, + releaseStartupTerminalTabHold, + selectParkedEquivalentMountTabIds, + type StartupTerminalTabHold +} 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' @@ -67,6 +72,7 @@ function useStartupHoldHarness(props: HarnessProps) { const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map>()) const lastActivationWorktreeIdRef = useRef(null) + const startupTerminalTabHoldRef = useRef(null) const mountedWorktreeIdsRef = useRef(new Set()) const activationDeferralPlanRevisionRef = useRef(0) const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) @@ -91,6 +97,7 @@ function useStartupHoldHarness(props: HarnessProps) { pendingStartupByTabId: {}, renderedActiveWorktreeId: props.worktreeId, setBackgroundMountRevision, + startupTerminalTabHoldRef, startupWorktreeRefreshCompleted: props.gateOpen, tabsByWorktree: useAppStore.getState().tabsByWorktree, terminalParkingEnabled: true, @@ -105,7 +112,8 @@ function useStartupHoldHarness(props: HarnessProps) { activationDeferredMountTabIdsByWorktreeRef, anyMountedWorktreeHasLayout: coldActivation.anyMountedWorktreeHasLayout, backgroundMountTabIdsByWorktreeRef, - mountedWorktreeIdsRef + mountedWorktreeIdsRef, + startupTerminalTabHold: coldActivation.startupTerminalTabHold } } @@ -157,6 +165,11 @@ describe('startup terminal tab hold', () => { expect(result.current.activationDeferredMountTabIdsByWorktreeRef.current.has(WORKTREE_ID)).toBe( false ) + // Held tabs stay parked-equivalent, so watchers own their bells, titles, and completions. + expect(result.current.startupTerminalTabHold).toEqual({ + worktreeId: WORKTREE_ID, + heldTabIds: new Set([TAB_1, TAB_2]) + }) act(() => { vi.advanceTimersByTime(1_000) }) @@ -167,6 +180,7 @@ describe('startup terminal tab hold', () => { 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) + expect(result.current.startupTerminalTabHold).toBeNull() }) it('returns a workspace switched away from mid-startup to the unmounted world', () => { @@ -186,6 +200,42 @@ describe('startup terminal tab hold', () => { expect(admits(restrictions, OTHER_WORKTREE_ID, TAB_1)).toBe(true) }) + it('returns a workspace left for no workspace mid-startup to the unmounted world', () => { + const initialProps: HarnessProps = { worktreeId: WORKTREE_ID, gateOpen: false } + const { result, rerender } = renderHook(useStartupHoldHarness, { initialProps }) + expect(result.current.mountedWorktreeIdsRef.current.has(WORKTREE_ID)).toBe(true) + + rerender({ worktreeId: null, gateOpen: false }) + expect(result.current.mountedWorktreeIdsRef.current.has(WORKTREE_ID)).toBe(false) + expect(result.current.backgroundMountTabIdsByWorktreeRef.current.has(WORKTREE_ID)).toBe(false) + expect(result.current.startupTerminalTabHold).toBeNull() + + rerender({ worktreeId: null, gateOpen: true }) + expect(result.current.mountedWorktreeIdsRef.current.size).toBe(0) + }) + + it('keeps holding a widened hold whose targeted tab closed', () => { + const { result, rerender } = renderHook(useStartupHoldHarness, { + initialProps: { worktreeId: WORKTREE_ID, gateOpen: false } + }) + const restrictions = result.current.backgroundMountTabIdsByWorktreeRef.current + // A targeted background mount widens the hold to its tab. + restrictions.set(WORKTREE_ID, new Set([TAB_1])) + act(() => { + useAppStore.setState({ + tabsByWorktree: { + ...useAppStore.getState().tabsByWorktree, + [WORKTREE_ID]: [terminalTab(TAB_2, WORKTREE_ID)] + } + }) + }) + rerender({ worktreeId: WORKTREE_ID, gateOpen: false }) + + expect(result.current.mountedWorktreeIdsRef.current.has(WORKTREE_ID)).toBe(true) + expect(admits(restrictions, WORKTREE_ID, TAB_2)).toBe(false) + expect(result.current.startupTerminalTabHold?.heldTabIds).toEqual(new Set([TAB_2])) + }) + it('does not mount a surface with no active workspace', () => { const { result } = renderHook(useStartupHoldHarness, { initialProps: { worktreeId: null, gateOpen: false } @@ -197,37 +247,53 @@ describe('startup terminal tab hold', () => { describe('holdTerminalTabsForStartup', () => { it('admits no terminal tab of a worktree that has not mounted yet', () => { + const hold: { current: StartupTerminalTabHold | null } = { current: null } const restrictions = new Map>() - const deferred = new Map>() const mounted = new Set() - holdTerminalTabsForStartup(restrictions, deferred, mounted, 'wt-active') + holdTerminalTabsForStartup(hold, restrictions, mounted, 'wt-active', ['tab-1']) expect(restrictions.get('wt-active')).toEqual(new Set()) + expect(mounted.has('wt-active')).toBe(true) expect(shouldMountBackgroundWorktreeTab(restrictions.get('wt-active') ?? null, 'tab-1')).toBe( false ) - expect(deferred.has('wt-active')).toBe(false) + expect(hold.current).toEqual({ worktreeId: 'wt-active', heldTabIds: new Set(['tab-1']) }) }) it('keeps a targeted background mount that landed first and never narrows a full mount', () => { + const hold: { current: StartupTerminalTabHold | null } = { current: null } 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') + holdTerminalTabsForStartup(hold, restrictions, mounted, 'wt-active', ['tab-1', 'tab-2']) expect(restrictions.get('wt-active')).toEqual(new Set(['tab-1'])) + expect(hold.current?.heldTabIds).toEqual(new Set(['tab-2'])) - holdTerminalTabsForStartup(restrictions, deferred, mounted, 'wt-full') + releaseStartupTerminalTabHold(hold, restrictions, mounted, 'wt-full') + holdTerminalTabsForStartup(hold, restrictions, mounted, 'wt-full', ['tab-1']) expect(restrictions.has('wt-full')).toBe(false) + expect(hold.current?.heldTabIds).toEqual(new Set()) + }) + + it('keeps the held set identity while the hold is unchanged', () => { + const hold: { current: StartupTerminalTabHold | null } = { current: null } + const restrictions = new Map>() + const mounted = new Set() + holdTerminalTabsForStartup(hold, restrictions, mounted, 'wt-active', ['tab-1']) + const first = hold.current + + holdTerminalTabsForStartup(hold, restrictions, mounted, 'wt-active', ['tab-1']) + + expect(hold.current).toBe(first) }) it('survives prune and reveal passes untouched', () => { + const hold: { current: StartupTerminalTabHold | null } = { current: null } const restrictions = new Map>() const deferred = new Map>() const mounted = new Set() - holdTerminalTabsForStartup(restrictions, deferred, mounted, 'wt-active') - mounted.add('wt-active') + holdTerminalTabsForStartup(hold, restrictions, mounted, 'wt-active', ['tab-1']) expect( pruneClosedBackgroundMountTabs( @@ -248,25 +314,36 @@ describe('holdTerminalTabsForStartup', () => { 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']) + it('releases an unwidened hold and leaves a widened one as a targeted restriction', () => { + const hold: { current: StartupTerminalTabHold | null } = { current: null } + const restrictions = new Map>() + const mounted = new Set() + holdTerminalTabsForStartup(hold, restrictions, mounted, 'wt-previous', ['tab-1']) - holdTerminalTabsForStartup(restrictions, deferred, mounted, 'wt-active') + releaseStartupTerminalTabHold(hold, restrictions, mounted, 'wt-previous') + expect(hold.current?.worktreeId).toBe('wt-previous') + releaseStartupTerminalTabHold(hold, restrictions, mounted, null) expect(restrictions.has('wt-previous')).toBe(false) expect(mounted.has('wt-previous')).toBe(false) + expect(hold.current).toBeNull() + + holdTerminalTabsForStartup(hold, restrictions, mounted, 'wt-targeted', ['tab-wake', 'tab-2']) + restrictions.set('wt-targeted', new Set(['tab-wake'])) + releaseStartupTerminalTabHold(hold, restrictions, mounted, 'wt-active') 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()) + }) +}) + +describe('selectParkedEquivalentMountTabIds', () => { + const hold: StartupTerminalTabHold = { worktreeId: 'wt-held', heldTabIds: new Set(['tab-1']) } + + it('prefers the activation deferral, then the hold, for the held worktree only', () => { + const deferred = new Set(['tab-deferred']) + expect(selectParkedEquivalentMountTabIds(deferred, hold, 'wt-held')).toBe(deferred) + expect(selectParkedEquivalentMountTabIds(undefined, hold, 'wt-held')).toBe(hold.heldTabIds) + expect(selectParkedEquivalentMountTabIds(undefined, hold, 'wt-other')).toBeNull() + expect(selectParkedEquivalentMountTabIds(undefined, null, 'wt-held')).toBeNull() }) }) diff --git a/src/renderer/src/components/terminal/startup-terminal-tab-hold.ts b/src/renderer/src/components/terminal/startup-terminal-tab-hold.ts index a09b12ac26f..6398911e5c3 100644 --- a/src/renderer/src/components/terminal/startup-terminal-tab-hold.ts +++ b/src/renderer/src/components/terminal/startup-terminal-tab-hold.ts @@ -1,44 +1,77 @@ -import { applyBackgroundMountTabRestriction } from './background-terminal-worktree-mount' +import { + applyBackgroundMountTabRestriction, + collectDeferredMountTabIds +} 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>, +export type StartupTerminalTabHold = { worktreeId: string -): boolean { - return restrictions.get(worktreeId)?.size === 0 && !deferredMountTabIdsByWorktree.has(worktreeId) + /** Tabs the hold keeps unmounted — parked-equivalent, so watchers own their side effects. */ + heldTabIds: ReadonlySet } /** - * 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. + * Ends the hold unless `nextHeldWorktreeId` is still the held worktree. Must run before the + * activation plan, which would otherwise read the hold as a plan it had installed. + * + * Why an unwidened hold unmounts: nothing but the surface was mounted under it, so a worktree + * switched away from — or left for no workspace — mid-startup returns to the unmounted world + * where parked watchers cover it. A hold a targeted background mount widened keeps that mount's + * tabs, like any targeted restriction. + */ +export function releaseStartupTerminalTabHold( + hold: { current: StartupTerminalTabHold | null }, + restrictions: Map>, + mountedWorktreeIds: Set, + nextHeldWorktreeId: string | null +): void { + const held = hold.current + if (!held || held.worktreeId === nextHeldWorktreeId) { + return + } + hold.current = null + if (restrictions.get(held.worktreeId)?.size === 0) { + restrictions.delete(held.worktreeId) + mountedWorktreeIds.delete(held.worktreeId) + } +} + +/** + * Keeps the worktree's terminal tabs 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. Runs after prune, so no prune pass can drop the hold before render. * * 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. + * worktrees with a deferral entry, so nothing admits a held tab until the startup gate opens + * and the activation plan replaces the hold. A targeted background mount widens it to its + * tabs, as it widens any restriction; a worktree already fully mounted is never narrowed. */ export function holdTerminalTabsForStartup( + hold: { current: StartupTerminalTabHold | null }, restrictions: Map>, - deferredMountTabIdsByWorktree: ReadonlyMap>, mountedWorktreeIds: Set, - worktreeId: string + worktreeId: string, + tabIds: readonly string[] ): void { - for (const [heldWorktreeId] of restrictions) { - if ( - heldWorktreeId !== worktreeId && - isStartupTerminalTabHold(restrictions, deferredMountTabIdsByWorktree, heldWorktreeId) - ) { - restrictions.delete(heldWorktreeId) - mountedWorktreeIds.delete(heldWorktreeId) - } - } applyBackgroundMountTabRestriction(restrictions, mountedWorktreeIds, worktreeId, []) + mountedWorktreeIds.add(worktreeId) + const heldTabIds = collectDeferredMountTabIds(restrictions.get(worktreeId) ?? null, tabIds) + const previous = hold.current + // Why reuse: the set feeds a memoized surface prop, so an unchanged hold keeps its identity. + if ( + previous?.worktreeId === worktreeId && + previous.heldTabIds.size === heldTabIds.size && + Array.from(heldTabIds).every((tabId) => previous.heldTabIds.has(tabId)) + ) { + return + } + hold.current = { worktreeId, heldTabIds } +} + +/** The watcher-coverage set for a mounted worktree: activation-deferred tabs, else held tabs. */ +export function selectParkedEquivalentMountTabIds( + activationDeferredMountTabIds: ReadonlySet | undefined, + hold: StartupTerminalTabHold | null, + worktreeId: string +): ReadonlySet | null { + return activationDeferredMountTabIds ?? (hold?.worktreeId === worktreeId ? hold.heldTabIds : null) } diff --git a/src/renderer/src/components/use-terminal-parking-foundation.ts b/src/renderer/src/components/use-terminal-parking-foundation.ts index c368b299471..3123a881d20 100644 --- a/src/renderer/src/components/use-terminal-parking-foundation.ts +++ b/src/renderer/src/components/use-terminal-parking-foundation.ts @@ -12,6 +12,7 @@ import { takeAllPendingBackgroundTerminalWorktreeMounts, takePendingBackgroundTerminalWorktreeMount } from './terminal/background-terminal-worktree-mount' +import type { StartupTerminalTabHold } from './terminal/startup-terminal-tab-hold' import { combineTerminalWorktreeParkIds, useManualTerminalWorktreeParking @@ -68,6 +69,7 @@ export function useTerminalParkingFoundation(controller: TerminalEditorCloseCont const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map>()) const lastActivationWorktreeIdRef = useRef(null) + const startupTerminalTabHoldRef = useRef(null) // Why a ref, not state: the cold-activation pass runs during render, where a // setState would be a render-phase update; the pass returns the count instead. const activationDeferralPlanRevisionRef = useRef(0) @@ -167,6 +169,7 @@ export function useTerminalParkingFoundation(controller: TerminalEditorCloseCont backgroundMountTabIdsByWorktreeRef, activationDeferredMountTabIdsByWorktreeRef, lastActivationWorktreeIdRef, + startupTerminalTabHoldRef, activationDeferralPlanRevisionRef } } diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index e840c3f9ba0..61c3c060061 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -15,6 +15,7 @@ import { createWorkspaceTerminalHostAuthoritySelector } from '@/lib/workspace-te import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle' import type { TerminalColdActivationController } from './terminal-cold-activation' +import { selectParkedEquivalentMountTabIds } from './terminal/startup-terminal-tab-hold' // Why shared: surfaces without watchable live tabs need no per-pass allocation. const NO_PARKED_TAB_IDS: ReadonlySet = new Set() @@ -41,6 +42,7 @@ type TerminalWatcherController = Pick< | 'pendingStartupByTabId' | 'reconcileWorktreeTabModel' | 'renderedActiveWorktreeId' + | 'startupTerminalTabHold' | 'tabsByWorktree' | 'terminalParkingEnabled' | 'terminalProviderSnapshotCapabilityRevision' @@ -73,6 +75,7 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) pendingStartupByTabId, reconcileWorktreeTabModel, renderedActiveWorktreeId, + startupTerminalTabHold, tabsByWorktree, terminalParkingEnabled, terminalProviderSnapshotCapabilityRevision, @@ -118,7 +121,11 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) } } } - deferredTabIds = activationDeferredMountTabIdsByWorktreeRef.current.get(workspaceId) ?? null + deferredTabIds = selectParkedEquivalentMountTabIds( + activationDeferredMountTabIdsByWorktreeRef.current.get(workspaceId), + startupTerminalTabHold, + workspaceId + ) for (const tab of tabs) { if ( deferredTabIds?.has(tab.id) && @@ -172,6 +179,7 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) pairedRuntimeParkingEnvironmentIds, pendingStartupByTabId, renderedActiveWorktreeId, + startupTerminalTabHold, tabsByWorktree, terminalParkingEnabled, terminalProviderSnapshotCapabilityRevision,