fix(workspace): a restored workspace paints before its terminals reconnect

After a restart the workspace content area stayed blank — no tab strip, no
pane — until the whole startup chain (SSH reconnect, PTY reconnect, legacy
worker recovery) had finished, even though the restored tab model had been in
the store for seconds. Only terminal panes need that chain, because a pane
binds a PTY on mount.

The workbench now mounts the active workspace from its hydrated tab model, and
holds its terminal tabs unadmitted (an empty admitted-tab restriction with no
deferral entry) until startup restoration has published PTY ownership; the
activation plan then replaces the hold. Chat, browser and editor panes mount
immediately.

The host also made restored chats readable only when a client first asked for
the tab inventory — a reply that itself waits on the renderer's runtime graph,
which is gated on the same terminal reconnect. The readable sweep now starts
from startup restoration behind a once-latch, still off the terminal-safety
fence, and the tab projection awaits the same sweep. A stream failure payload
is reported by its message instead of `[object Object]`.
This commit is contained in:
Brennan Benson
2026-09-22 10:17:59 -07:00
parent 3bb9a4e261
commit 922c1c2626
11 changed files with 430 additions and 25 deletions
@@ -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<void> {
this.structuredReadableSessionRestorePromise ??=
this.restoreReadableStructuredSessionsOnce().catch((error) => {
this.structuredReadableSessionRestorePromise = null
throw error
})
return this.structuredReadableSessionRestorePromise
}
private async restoreReadableStructuredSessionsOnce(): Promise<void> {
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<ReturnType<typeof getStructuredAgentSessionHost>>
): 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 {
@@ -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<void> {
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,
@@ -122,6 +122,7 @@ export class OrcaRuntimeWithRuntimeId {
protected structuredAgentSessionTabRestorePromise: Promise<void> | null = null
protected structuredAgentSessionStartupRestorePromise: Promise<void> | null = null
protected structuredReadableSessionRestorePromise: Promise<void> | null = null
protected mobileSessionTabsChangeSequence = 0
@@ -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<string>())
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<void>(() => undefined))
const internal = runtime as unknown as {
hasPersistedStructuredAgentSessionStore(): boolean
refreshMobileSessionPtyRecords(): Promise<Set<string> | 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 () => {
@@ -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 {
@@ -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<typeof setTimeout> | 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) => {
@@ -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
@@ -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
@@ -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
@@ -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<string, ReadonlySet<string>>())
const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
const lastActivationWorktreeIdRef = useRef<string | null>(null)
const mountedWorktreeIdsRef = useRef(new Set<string>())
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<string>(),
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<string, ReadonlySet<string>>,
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<string, ReadonlySet<string>>()
const deferred = new Map<string, ReadonlySet<string>>()
const mounted = new Set<string>()
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<string, ReadonlySet<string>>([['wt-active', new Set(['tab-1'])]])
const deferred = new Map<string, ReadonlySet<string>>()
const mounted = new Set<string>(['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<string, ReadonlySet<string>>()
const deferred = new Map<string, ReadonlySet<string>>()
const mounted = new Set<string>()
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<string, ReadonlySet<string>>([
['wt-previous', new Set()],
['wt-targeted', new Set(['tab-wake'])],
['wt-activation', new Set()]
])
const deferred = new Map<string, ReadonlySet<string>>([
['wt-activation', new Set(['tab-deferred'])]
])
const mounted = new Set<string>(['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())
})
})
@@ -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<string, ReadonlySet<string>>,
deferredMountTabIdsByWorktree: ReadonlyMap<string, ReadonlySet<string>>,
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<string, ReadonlySet<string>>,
deferredMountTabIdsByWorktree: ReadonlyMap<string, ReadonlySet<string>>,
mountedWorktreeIds: Set<string>,
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, [])
}