mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
fix(workspace): track the startup terminal hold explicitly and keep its tabs watched
The startup hold was inferred from the restriction map's shape and released only when a new hold was installed, so a workspace left for no workspace mid-startup stayed mounted with no terminal panes and no background watchers. Track the held workspace explicitly, release it on any pass where it is no longer held, apply it after prune, and count held tabs as parked so their bells, titles, and completions are still observed during the window. Gate the early readable sweep on the structured-chat setting, as the tab projection already is, and rely on the host's own once-per-process latch instead of a second runtime-level latch that latched success when no host existed.
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
/** 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<void> {
|
||||
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
|
||||
|
||||
@@ -122,7 +122,6 @@ export class OrcaRuntimeWithRuntimeId {
|
||||
protected structuredAgentSessionTabRestorePromise: Promise<void> | null = null
|
||||
|
||||
protected structuredAgentSessionStartupRestorePromise: Promise<void> | null = null
|
||||
protected structuredReadableSessionRestorePromise: Promise<void> | null = null
|
||||
|
||||
protected mobileSessionTabsChangeSequence = 0
|
||||
|
||||
|
||||
@@ -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<Set<string> | null>
|
||||
ensureStructuredAgentSessionHost(): Promise<void>
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -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 <webview>, 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
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ function renderWatcherEffects(overrides: Partial<WatcherController> = {}): 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()
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<string, ReadonlySet<string>>())
|
||||
const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
|
||||
const lastActivationWorktreeIdRef = useRef<string | null>(null)
|
||||
const startupTerminalTabHoldRef = useRef<StartupTerminalTabHold | null>(null)
|
||||
const mountedWorktreeIdsRef = useRef(new Set<string>())
|
||||
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<string>(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, ReadonlySet<string>>())
|
||||
const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
|
||||
const lastActivationWorktreeIdRef = useRef<string | null>(null)
|
||||
const startupTerminalTabHoldRef = useRef<StartupTerminalTabHold | null>(null)
|
||||
const mountedWorktreeIdsRef = useRef(new Set<string>())
|
||||
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<string, ReadonlySet<string>>()
|
||||
const deferred = new Map<string, ReadonlySet<string>>()
|
||||
const mounted = new Set<string>()
|
||||
|
||||
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<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')
|
||||
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<string, ReadonlySet<string>>()
|
||||
const mounted = new Set<string>()
|
||||
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<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')
|
||||
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<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'])
|
||||
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<string, ReadonlySet<string>>()
|
||||
const mounted = new Set<string>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, ReadonlySet<string>>,
|
||||
deferredMountTabIdsByWorktree: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
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<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, ReadonlySet<string>>,
|
||||
mountedWorktreeIds: Set<string>,
|
||||
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<string, ReadonlySet<string>>,
|
||||
deferredMountTabIdsByWorktree: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
mountedWorktreeIds: Set<string>,
|
||||
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<string> | undefined,
|
||||
hold: StartupTerminalTabHold | null,
|
||||
worktreeId: string
|
||||
): ReadonlySet<string> | null {
|
||||
return activationDeferredMountTabIds ?? (hold?.worktreeId === worktreeId ? hold.heldTabIds : null)
|
||||
}
|
||||
|
||||
@@ -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<string, ReadonlySet<string>>())
|
||||
const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
|
||||
const lastActivationWorktreeIdRef = useRef<string | null>(null)
|
||||
const startupTerminalTabHoldRef = useRef<StartupTerminalTabHold | null>(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> = 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,
|
||||
|
||||
Reference in New Issue
Block a user