diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 13301c9de9b..a31e5b89bc0 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -19,7 +19,10 @@ import { import type { PersistedState } from '../../../shared/persisted-state-types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { dropInvalidWorkspaceLayoutsAtLoad } from '../restoring-sessions/invalid-workspace-layout-purge' -import { bindPersistedTabTerminalPanes } from '../restoring-sessions/tab-terminal-pane-binding-repair' +import { + bindPersistedTabTerminalPanes, + sweepPersistedUnattachedTerminalRows +} from '../restoring-sessions/tab-terminal-pane-binding-repair' import { scheduleSave } from './write-scheduling' import { migrateLegacyOpenCodeGoApiKey, @@ -129,7 +132,7 @@ export class Store { const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue() if (ORCA_LAYOUT_OWNER_IS_MAIN) { dropInvalidWorkspaceLayoutsAtLoad(this, this.state) - this.state = bindPersistedTabTerminalPanes(this.state) + this.state = bindPersistedTabTerminalPanes(sweepPersistedUnattachedTerminalRows(this.state)) this.runtime.state = this.state } // Imported snapshots cannot own the live hook server or write their source file. diff --git a/src/main/persistence/restoring-sessions/tab-terminal-pane-binding-repair.ts b/src/main/persistence/restoring-sessions/tab-terminal-pane-binding-repair.ts index f30cb2b95be..dca3304bfa9 100644 --- a/src/main/persistence/restoring-sessions/tab-terminal-pane-binding-repair.ts +++ b/src/main/persistence/restoring-sessions/tab-terminal-pane-binding-repair.ts @@ -8,14 +8,10 @@ import { readCanonicalPtyOwners, resolveCanonicalPtyClaim } from '../../../shared/workspace-layout/legacy-terminal-row-claims' - -/** The reconnect plan's rule (workspace-terminal-reconnect-plan.ts): the shutdown list, else any row with a terminal. */ -function liveAtShutdown(session: WorkspaceSessionState): (key: string) => boolean { - const listed = session.activeWorktreeIdsOnShutdown - return listed - ? (key) => listed.includes(key) - : (key) => (session.tabsByWorktree[key] ?? []).some((row) => row.ptyId) -} +import { + liveAtShutdown, + sweepUnattachedTerminalRows +} from '../../../shared/workspace-layout/unattached-terminal-row-sweep' /** Terminals a pane in the layout's tree binds; a binding a removed pane left behind holds nothing. */ function mountedPtyIds(layout: TerminalLayoutSnapshot): string[] { @@ -74,15 +70,16 @@ export function bindTabTerminalPanesInSession( return layouts ? { ...session, terminalLayoutsByTabId: layouts } : session } -/** - * Load-only repair for layout-owner builds: main's layout holds these terminals from the start, so - * its frames and saves keep them before the pane mounts. A paired server's partition is the - * server's and passes through. - */ -export function bindPersistedTabTerminalPanes(state: PersistedState): PersistedState { +type SessionRepair = ( + session: WorkspaceSessionState, + isSshWorkspace: (key: string) => boolean +) => WorkspaceSessionState + +/** Repairs every partition this desktop restores; a paired server's is the server's and passes through. */ +function repairRestoredSessions(state: PersistedState, repair: SessionRepair): PersistedState { const sshRepoIds = new Set(state.repos.filter((repo) => repo.connectionId).map((repo) => repo.id)) const sshRepoWorkspace = (key: string): boolean => sshRepoIds.has(getRepoIdFromWorktreeId(key)) - const workspaceSession = bindTabTerminalPanesInSession(state.workspaceSession, sshRepoWorkspace) + const workspaceSession = repair(state.workspaceSession, sshRepoWorkspace) let hostSessionsChanged = false const workspaceSessionsByHostId = state.workspaceSessionsByHostId ? Object.fromEntries( @@ -90,10 +87,7 @@ export function bindPersistedTabTerminalPanes(state: PersistedState): PersistedS const kind = parseExecutionHostId(hostId)?.kind const repaired = session && kind !== 'runtime' - ? bindTabTerminalPanesInSession( - session, - kind === 'ssh' ? () => true : sshRepoWorkspace - ) + ? repair(session, kind === 'ssh' ? () => true : sshRepoWorkspace) : session hostSessionsChanged ||= repaired !== session return [hostId, repaired] @@ -109,3 +103,29 @@ export function bindPersistedTabTerminalPanes(state: PersistedState): PersistedS ...(hostSessionsChanged ? { workspaceSessionsByHostId } : {}) } } + +/** + * Load-only repair for layout-owner builds: main's layout holds these terminals from the start, so + * its frames and saves keep them before the pane mounts. + */ +export function bindPersistedTabTerminalPanes(state: PersistedState): PersistedState { + return repairRestoredSessions(state, bindTabTerminalPanesInSession) +} + +/** + * Load-only repair for layout-owner builds: rows outside the tab bar the window's startup would + * sweep go once, before main's layout would draw each as an empty tab. + */ +export function sweepPersistedUnattachedTerminalRows(state: PersistedState): PersistedState { + // Why any partition's list too: a stale list can name another partition's workspace, which the + // window's merged list then counts live; keeping on either never sweeps a row the window keeps. + const listedAnywhere = new Set( + [state.workspaceSession, ...Object.values(state.workspaceSessionsByHostId ?? {})].flatMap( + (session) => session?.activeWorktreeIdsOnShutdown ?? [] + ) + ) + return repairRestoredSessions(state, (session) => { + const ownLive = liveAtShutdown(session) + return sweepUnattachedTerminalRows(session, (key) => ownLive(key) || listedAnywhere.has(key)) + }) +} diff --git a/src/main/persistence/restoring-sessions/unattached-terminal-row-sweep.test.ts b/src/main/persistence/restoring-sessions/unattached-terminal-row-sweep.test.ts new file mode 100644 index 00000000000..5d0be938538 --- /dev/null +++ b/src/main/persistence/restoring-sessions/unattached-terminal-row-sweep.test.ts @@ -0,0 +1,181 @@ +// A stored terminal row with no tab-bar entry: main's load sweeps it once, as the window's startup +// does, unless the window's reconnect plan would reattach it; main's layout then never draws an +// empty extra terminal tab the window would not show. + +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { sleepingRecord } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture' +import { + addWorkspace, + emptySession, + GIT_KEY, + leaf +} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import { sweepPersistedUnattachedTerminalRows } from './tab-terminal-pane-binding-repair' + +const CLI_KEY = 'repo-1::/Users/dev/orca-cli' + +/** tab-a in the tab bar; tab-b only as a row, bound to nothing. */ +function unattachedRow(): WorkspaceSessionState { + const session = addWorkspace(emptySession(), GIT_KEY, [ + { + id: 'g1', + tabs: [ + { id: 'tab-a', leaves: [[leaf(1), 'pty-a']] }, + { id: 'tab-b', leaves: [[leaf(2)]] } + ] + } + ]) + session.unifiedTabs![GIT_KEY] = session.unifiedTabs![GIT_KEY]!.filter((tab) => tab.id !== 'tab-b') + session.tabGroups![GIT_KEY]![0]!.tabOrder = ['tab-a'] + return session +} + +function swept(session: WorkspaceSessionState): WorkspaceSessionState { + const state = getDefaultPersistedState('/home/dev') + state.workspaceSession = session + return sweepPersistedUnattachedTerminalRows(state).workspaceSession +} + +const rowIds = (session: WorkspaceSessionState, key = GIT_KEY): string[] => + (session.tabsByWorktree[key] ?? []).map((row) => row.id) + +describe('load sweep: a terminal row outside the tab bar', () => { + it('goes with its pane layout when nothing would reattach it', () => { + const session = swept(unattachedRow()) + expect(rowIds(session)).toEqual(['tab-a']) + expect(session.terminalLayoutsByTabId['tab-b']).toBeUndefined() + }) + + it('goes when only a pane binds a terminal, as the window does', () => { + const session = unattachedRow() + session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = { [leaf(2)]: 'pty-b' } + expect(rowIds(swept(session))).toEqual(['tab-a']) + }) + + it('goes when its workspace was not live at shutdown', () => { + const session = unattachedRow() + session.tabsByWorktree[GIT_KEY]![1]!.ptyId = 'pty-b' + session.activeWorktreeIdsOnShutdown = [] + expect(rowIds(swept(session))).toEqual(['tab-a']) + }) + + it('leaves its sleeping agent record in place, as the window does', () => { + const session = unattachedRow() + const paneKey = `tab-b:${leaf(2)}` + session.sleepingAgentSessionsByPaneKey = { + [paneKey]: sleepingRecord(GIT_KEY, 'tab-b', leaf(2)) + } + const after = swept(session) + expect(rowIds(after)).toEqual(['tab-a']) + expect(Object.keys(after.sleepingAgentSessionsByPaneKey ?? {})).toEqual([paneKey]) + }) + + it('moves selections off it: the workspace to its first kept row, the global one to none', () => { + const session = unattachedRow() + session.activeTabId = 'tab-b' + session.activeTabIdByWorktree = { [GIT_KEY]: 'tab-b' } + const after = swept(session) + expect(after.activeTabIdByWorktree).toEqual({ [GIT_KEY]: 'tab-a' }) + expect(after.activeTabId).toBeNull() + }) + + it('stays when its own terminal would reattach', () => { + const session = unattachedRow() + session.tabsByWorktree[GIT_KEY]![1]!.ptyId = 'pty-b' + expect(swept(session)).toBe(session) + }) + + it('stays when its terminal is on an SSH relay', () => { + const session = unattachedRow() + session.remoteSessionIdsByTabId = { 'tab-b': 'relay-b' } + expect(swept(session)).toBe(session) + }) + + it('stays when a split row keeps its own pane’s terminal after the tab bar takes the shared one', () => { + const session = unattachedRow() + session.terminalLayoutsByTabId['tab-b'] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: leaf(2) }, + second: { type: 'leaf', leafId: leaf(3) } + }, + activeLeafId: leaf(2), + expandedLeafId: null, + ptyIdsByLeafId: { [leaf(2)]: 'pty-a', [leaf(3)]: 'pty-c' } + } + expect(swept(session)).toBe(session) + }) + + it('stays when the CLI started it in a workspace the window never opened', () => { + const session = unattachedRow() + session.tabsByWorktree[GIT_KEY]![1]!.ptyId = 'pty-b' + session.tabsByWorktree[CLI_KEY] = [ + { + ...session.tabsByWorktree[GIT_KEY]![1]!, + id: 'tab-cli', + worktreeId: CLI_KEY, + ptyId: 'pty-cli' + } + ] + expect(swept(session)).toBe(session) + }) + + it('sweeps SSH partitions too and passes a paired server’s through', () => { + const state = getDefaultPersistedState('/home/dev') + const sshHost = toSshExecutionHostId('box') + const serverHost = toRuntimeExecutionHostId('env-1') + const serverSession = unattachedRow() + state.workspaceSessionsByHostId = { [sshHost]: unattachedRow(), [serverHost]: serverSession } + const after = sweepPersistedUnattachedTerminalRows(state).workspaceSessionsByHostId! + expect(rowIds(after[sshHost]!)).toEqual(['tab-a']) + expect(after[serverHost]).toBe(serverSession) + }) + + it('stays when it is an SSH workspace’s and only its own terminal id names one, as the window may reattach it', () => { + const state = getDefaultPersistedState('/home/dev') + state.repos = [ + { + id: 'repo-1', + path: '/Users/dev/orca', + displayName: 'orca', + badgeColor: '#000', + addedAt: 0, + connectionId: 'box' + } + ] + const session = unattachedRow() + session.tabsByWorktree[GIT_KEY]![1]!.ptyId = 'pty-b' + state.workspaceSession = session + expect(rowIds(sweepPersistedUnattachedTerminalRows(state).workspaceSession)).toEqual([ + 'tab-a', + 'tab-b' + ]) + }) + + it('stays when another partition’s stale shutdown list names its workspace, as the merged list does', () => { + const state = getDefaultPersistedState('/home/dev') + const sshHost = toSshExecutionHostId('box') + const local = unattachedRow() + local.tabsByWorktree[GIT_KEY]![1]!.ptyId = 'pty-b' + local.activeWorktreeIdsOnShutdown = [] + const remote = emptySession() + remote.activeWorktreeIdsOnShutdown = [GIT_KEY] + state.workspaceSession = local + state.workspaceSessionsByHostId = { [sshHost]: remote } + expect(sweepPersistedUnattachedTerminalRows(state).workspaceSession).toBe(local) + }) + + it('treats a session from before the tab bar as the window does: keeps a live row, sweeps the rest', () => { + const session = unattachedRow() + delete session.unifiedTabs + delete session.tabGroups + delete session.tabGroupLayouts + const after = swept(session) + expect(rowIds(after)).toEqual(['tab-a']) + expect(after.terminalLayoutsByTabId['tab-a']).toBeDefined() + }) +}) diff --git a/src/renderer/src/app-shell/startup-window-frame-parity.test.ts b/src/renderer/src/app-shell/startup-window-frame-parity.test.ts index 5aaa77c917e..a145ce2439a 100644 --- a/src/renderer/src/app-shell/startup-window-frame-parity.test.ts +++ b/src/renderer/src/app-shell/startup-window-frame-parity.test.ts @@ -39,7 +39,8 @@ import { import { ACCEPTED_DIFFERENCES, GIT, - KNOWN_DIFFERENCES + KNOWN_DIFFERENCES, + sweptAtStartup } from './startup-window-parity-differences.test-fixture' import { bootWindowFromPartitions, @@ -47,6 +48,13 @@ import { projectStartupWindowView } from './startup-window-view.test-fixture' +// Main and the frame applier run as in a layout-owner build; the restore they match runs as today's. +const layoutOwner = vi.hoisted(() => ({ isMain: true })) +vi.mock('../../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return layoutOwner.isMain + } +})) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() @@ -153,11 +161,11 @@ const PROFILES: readonly WorkspaceLayoutProfile[] = [ { name: 'groups with no selected tab', hostId: 'local', build: groupsWithoutSelection } ] -/** What main publishes for a stored partition, and what the Loader keeps beside the layout. */ +/** What main publishes for a stored partition after its startup sweep, and what the Loader keeps beside the layout. */ function frameInput(hostId: ExecutionHostId, stored: WorkspaceSessionState): LayoutFrameSliceInput { const { layout, facts, desktopView } = loadWorkspaceLayoutWithoutReport( hostId, - stored, + sweptAtStartup(hostId, stored), nameBasedLoadContext() ) return { @@ -220,16 +228,6 @@ type SliceDifference = { /** Slice paths that differ. Entries may only be removed. */ const SLICE_DIFFERENCES: readonly SliceDifference[] = [ - { - fixture: 'row without a tab-bar entry or a terminal', - cause: 'ghost tab (Loader parity)', - paths: [ - `tabsByWorktree ${GIT} 1`, - 'terminalLayoutsByTabId tab-b', - `unifiedTabsByWorktree ${GIT} 1`, - `groupsByWorktree ${GIT} 0 tabOrder 1` - ] - }, { fixture: 'legacy surface tombstone on a pane still showing its terminal', cause: 'tombstones (Loader parity)', @@ -251,6 +249,11 @@ const SLICE_DIFFERENCES: readonly SliceDifference[] = [ 'host stamp: a frame names every tab’s host; an adopted tab-bar entry leaves it absent, which readers take as the workspace’s', paths: [`unifiedTabsByWorktree ${GIT} 1 executionHostId`] }, + { + fixture: 'row without a tab-bar entry, its terminal only on a relay', + cause: 'host stamp (above)', + paths: [`unifiedTabsByWorktree ${GIT} 1 executionHostId`] + }, { fixture: 'legacy split row sharing one pane’s terminal with a tab-bar tab', cause: 'host stamp (above)', @@ -270,6 +273,18 @@ const SLICE_DIFFERENCES: readonly SliceDifference[] = [ `activeGroupIdByWorktree ${GIT}`, `layoutByWorktree ${GIT} groupId` ] + }, + { + fixture: 'session from before the tab bar, a row with no terminal', + cause: 'root group and host stamp (above), for the one kept tab', + paths: [ + `unifiedTabsByWorktree ${GIT} 0 groupId`, + `unifiedTabsByWorktree ${GIT} 0 executionHostId`, + `groupsByWorktree ${GIT} 0 id`, + `groupsByWorktree ${GIT} 0 recentTabIds`, + `activeGroupIdByWorktree ${GIT}`, + `layoutByWorktree ${GIT} groupId` + ] } ] @@ -278,9 +293,12 @@ async function restoreAndApply(profile: WorkspaceLayoutProfile): Promise<{ applied: LayoutFrameSlices }> { const stored = profile.build() + layoutOwner.isMain = false const restored = await bootWindowFromPartitions(createTestStore(), [ { hostId: profile.hostId, session: stored } - ]) + ]).finally(() => { + layoutOwner.isMain = true + }) return { restored, applied: buildLayoutFrameSlices(frameInput(profile.hostId, stored)) } } diff --git a/src/renderer/src/app-shell/startup-window-parity-differences.test-fixture.ts b/src/renderer/src/app-shell/startup-window-parity-differences.test-fixture.ts index 6af1062ee87..37d94fb9489 100644 --- a/src/renderer/src/app-shell/startup-window-parity-differences.test-fixture.ts +++ b/src/renderer/src/app-shell/startup-window-parity-differences.test-fixture.ts @@ -2,11 +2,21 @@ // each with the Loader rule that causes it. Shared by every parity test that compares a window // built from the Loader's result with the window restored from disk. +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { sweepUnattachedTerminalRows } from '../../../shared/workspace-layout/unattached-terminal-row-sweep' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + export const GIT = 'repo-1::/Users/dev/orca' -const terminal = (tabId: string) => - ['phone and CLI title', 'panes', 'focused pane', 'reattaches'].map( - (field) => `${GIT} terminal ${tabId} ${field}` - ) + +/** Main's startup sweep in a layout-owner build; a paired server's partition passes through. */ +export function sweptAtStartup( + hostId: ExecutionHostId, + session: WorkspaceSessionState +): WorkspaceSessionState { + return parseExecutionHostId(hostId)?.kind === 'runtime' + ? session + : sweepUnattachedTerminalRows(session) +} export type KnownDifference = { fixture: string @@ -17,13 +27,7 @@ export type KnownDifference = { } /** Differences to fix. Entries may only be removed: fix the rule, then delete its entry. */ -export const KNOWN_DIFFERENCES: readonly KnownDifference[] = [ - { - fixture: 'row without a tab-bar entry or a terminal', - rule: 'ghost tab: the Loader gives every row a tab-bar entry; the window adopts only rows it can reattach', - paths: [`${GIT} group 0 tabs`, `${GIT} tab tab-b`, ...terminal('tab-b')] - } -] +export const KNOWN_DIFFERENCES: readonly KnownDifference[] = [] export type AcceptedDifference = Omit & { reason: string diff --git a/src/renderer/src/app-shell/startup-window-parity.test.ts b/src/renderer/src/app-shell/startup-window-parity.test.ts index 44b897fbe3a..b08f0080859 100644 --- a/src/renderer/src/app-shell/startup-window-parity.test.ts +++ b/src/renderer/src/app-shell/startup-window-parity.test.ts @@ -16,7 +16,8 @@ import { createTestStore } from '../store/slices/store-test-helpers' import { ACCEPTED_DIFFERENCES, GIT, - KNOWN_DIFFERENCES + KNOWN_DIFFERENCES, + sweptAtStartup } from './startup-window-parity-differences.test-fixture' import { bootWindowFromPartitions, @@ -24,6 +25,13 @@ import { projectStartupWindowView } from './startup-window-view.test-fixture' +// The window boots as today's (the oracle); main loads as it does once it owns the layout. +const layoutOwner = vi.hoisted(() => ({ isMain: false })) +vi.mock('../../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return layoutOwner.isMain + } +})) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() @@ -32,14 +40,19 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { createStoreSessionMockApi() -/** The shared Loader's round trip, as main would persist it. */ +/** Main's startup sweep, then the shared Loader's round trip, as main would persist it. */ function normalize(hostId: ExecutionHostId, session: WorkspaceSessionState): WorkspaceSessionState { let next = 0 - const loaded = loadWorkspaceLayout(hostId, structuredClone(session), { - mintId: () => `minted-${++next}`, - mintLeafId: () => `00000000-0000-4000-9000-${String(++next).padStart(12, '0')}` - }) - return saveWorkspaceLayout(loaded) + layoutOwner.isMain = true + try { + const loaded = loadWorkspaceLayout(hostId, sweptAtStartup(hostId, structuredClone(session)), { + mintId: () => `minted-${++next}`, + mintLeafId: () => `00000000-0000-4000-9000-${String(++next).padStart(12, '0')}` + }) + return saveWorkspaceLayout(loaded) + } finally { + layoutOwner.isMain = false + } } const PROFILES = [...WORKSPACE_LAYOUT_PROFILES, ...LEGACY_WORKSPACE_LAYOUT_PROFILES] diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts index 892a66cb0bb..e276b1f37fe 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts @@ -120,11 +120,17 @@ async function bootWithFrames(hostId: ExecutionHostId, session: WorkspaceSession /** Fixtures whose frames differ from the restore, as the frame parity test lists. */ const FRAME_DIFFERENCE_FIXTURES = new Set([ 'row without a tab-bar entry or a terminal', + 'row without a tab-bar entry or a terminal, selected', + 'row without a tab-bar entry, its terminal in a workspace slept at shutdown', + 'row without a tab-bar entry, its terminal only on a relay', + 'row without a tab-bar entry, with a sleeping agent', + 'row bound only through its pane, without a tab-bar entry', 'legacy surface tombstone on a pane still showing its terminal', 'tab-bar label differing from the row title', 'row without a tab-bar entry, still bound to a terminal', 'legacy split row sharing one pane’s terminal with a tab-bar tab', - 'session from before the tab bar, with an open file' + 'session from before the tab bar, with an open file', + 'session from before the tab bar, a row with no terminal' ]) afterEach(() => endLayoutFramePositions()) diff --git a/src/shared/workspace-layout/unattached-terminal-row-sweep.ts b/src/shared/workspace-layout/unattached-terminal-row-sweep.ts new file mode 100644 index 00000000000..eedca54b0ad --- /dev/null +++ b/src/shared/workspace-layout/unattached-terminal-row-sweep.ts @@ -0,0 +1,101 @@ +// The window's startup sweep of stored terminal rows outside the tab bar, read from stored data +// alone, so main's load can apply it once before main owns the layout. The window adopts such a +// row only when its startup reconnect plan (workspace-terminal-reconnect-plan.ts) names a terminal +// for it; the orphan sweep (terminal-orphan-helpers.ts) removes the rest with their pane layouts. + +import { isValidTerminalTabId } from '../terminal-tab-id' +import type { TerminalTab } from '../terminal-tab-types' +import type { WorkspaceSessionState } from '../workspace-session-state-types' +import { readCanonicalPtyOwners, resolveCanonicalPtyClaim } from './legacy-terminal-row-claims' + +/** + * The reconnect plan's rule: the shutdown list, else any row with a terminal. Read per partition, + * since each lists only its own workspaces. + */ +export function liveAtShutdown(session: WorkspaceSessionState): (key: string) => boolean { + const listed = session.activeWorktreeIdsOnShutdown + return listed + ? (key) => listed.includes(key) + : (key) => (session.tabsByWorktree[key] ?? []).some((row) => row.ptyId) +} + +type RowSweep = { + /** Rows outside the tab bar the reconnect plan names no terminal for. */ + unattached: Set + /** Rows the window's hydration keeps before its sweep; a selection falls back to the first. */ + hydrated: TerminalTab[] +} + +function sweepRows( + session: WorkspaceSessionState, + key: string, + rows: readonly TerminalTab[], + live: boolean +): RowSweep { + const canonical = readCanonicalPtyOwners(session, key, rows) + const unattached = new Set() + const hydrated: TerminalTab[] = [] + for (const row of rows) { + // Hydration drops these itself; a subsumed row is the Loader's, following to its twin. + if (!isValidTerminalTabId(row.id)) { + continue + } + const claim = resolveCanonicalPtyClaim(session, row, canonical) + if (claim.kind === 'subsumed') { + continue + } + hydrated.push(row) + if (canonical.tabIds.has(row.id)) { + continue + } + // Why not SSH-aware: the window ignores an SSH row's own id only when it finds the worktree + // listed, which main cannot know at load; keeping errs to a ghost, never a lost terminal. + const named = [live ? row.ptyId : null, session.remoteSessionIdsByTabId?.[row.id]] + const reattaches = + named.some((ptyId) => ptyId && !claim.releasedPtyIds.has(ptyId)) || claim.reconnectPtyId + if (!reattaches) { + unattached.add(row.id) + } + } + return { unattached, hydrated } +} + +/** + * Drops each stored terminal row the window's startup would sweep, with its pane layout, and + * moves a selection off it as the sweep does. Resume and close records stay, as they do there. + */ +export function sweepUnattachedTerminalRows( + session: WorkspaceSessionState, + live: (key: string) => boolean = liveAtShutdown(session) +): WorkspaceSessionState { + let next: WorkspaceSessionState | null = null + for (const [key, rows] of Object.entries(session.tabsByWorktree ?? {})) { + const { unattached, hydrated } = sweepRows(session, key, rows, live(key)) + if (unattached.size === 0) { + continue + } + next ??= { + ...session, + tabsByWorktree: { ...session.tabsByWorktree }, + terminalLayoutsByTabId: { ...session.terminalLayoutsByTabId }, + ...(session.activeTabIdByWorktree + ? { activeTabIdByWorktree: { ...session.activeTabIdByWorktree } } + : {}) + } + next.tabsByWorktree[key] = rows.filter((row) => !unattached.has(row.id)) + for (const tabId of unattached) { + delete next.terminalLayoutsByTabId[tabId] + } + const selected = next.activeTabIdByWorktree?.[key] + if (next.activeTabIdByWorktree && selected && unattached.has(selected)) { + const [first] = hydrated + .filter((row) => !unattached.has(row.id)) + .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) + next.activeTabIdByWorktree[key] = first?.id ?? null + } + if (next.activeTabId && unattached.has(next.activeTabId)) { + next.activeTabId = null + } + } + return next ?? session +} diff --git a/src/shared/workspace-layout/workspace-layout-legacy-profile.test-fixture.ts b/src/shared/workspace-layout/workspace-layout-legacy-profile.test-fixture.ts index d9961a9ca66..de82af89239 100644 --- a/src/shared/workspace-layout/workspace-layout-legacy-profile.test-fixture.ts +++ b/src/shared/workspace-layout/workspace-layout-legacy-profile.test-fixture.ts @@ -3,7 +3,10 @@ import { LOCAL_EXECUTION_HOST_ID } from '../execution-host' import type { WorkspaceSessionState } from '../workspace-session-state-types' -import type { WorkspaceLayoutProfile } from './workspace-layout-profile.test-fixture' +import { + sleepingRecord, + type WorkspaceLayoutProfile +} from './workspace-layout-profile.test-fixture' import { addWorkspace, emptySession, GIT_KEY, leaf } from './workspace-layout-session.test-fixture' function twoTabs(): WorkspaceSessionState { @@ -39,10 +42,44 @@ export const LEGACY_WORKSPACE_LAYOUT_PROFILES: readonly WorkspaceLayoutProfile[] session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = {} return session }), - // Control for the case above: the window adopts a row it can reattach, as the Loader does. + local('row without a tab-bar entry or a terminal, selected', () => { + const session = withoutTabBarEntry(twoTabs(), 'tab-b') + session.tabsByWorktree[GIT_KEY]![1]!.ptyId = null + session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = {} + session.activeTabId = 'tab-b' + session.activeTabIdByWorktree = { [GIT_KEY]: 'tab-b' } + return session + }), + local('row without a tab-bar entry, its terminal in a workspace slept at shutdown', () => { + const session = withoutTabBarEntry(twoTabs(), 'tab-b') + session.activeWorktreeIdsOnShutdown = [] + return session + }), + // Controls for the cases above: the window adopts a row it can reattach, as the Loader does. local('row without a tab-bar entry, still bound to a terminal', () => withoutTabBarEntry(twoTabs(), 'tab-b') ), + local('row without a tab-bar entry, its terminal only on a relay', () => { + const session = withoutTabBarEntry(twoTabs(), 'tab-b') + session.tabsByWorktree[GIT_KEY]![1]!.ptyId = null + session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = {} + session.remoteSessionIdsByTabId = { 'tab-b': 'relay-b' } + return session + }), + local('row without a tab-bar entry, with a sleeping agent', () => { + const session = withoutTabBarEntry(twoTabs(), 'tab-b') + session.tabsByWorktree[GIT_KEY]![1]!.ptyId = null + session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = {} + session.sleepingAgentSessionsByPaneKey = { + [`tab-b:${leaf(2)}`]: sleepingRecord(GIT_KEY, 'tab-b', leaf(2)) + } + return session + }), + local('row bound only through its pane, without a tab-bar entry', () => { + const session = withoutTabBarEntry(twoTabs(), 'tab-b') + session.tabsByWorktree[GIT_KEY]![1]!.ptyId = null + return session + }), local('legacy row repeating a tab-bar tab’s terminal', () => { const session = twoTabs() session.tabsByWorktree[GIT_KEY]!.push({ @@ -140,6 +177,16 @@ export const LEGACY_WORKSPACE_LAYOUT_PROFILES: readonly WorkspaceLayoutProfile[] session.activeTabTypeByWorktree = { [GIT_KEY]: 'editor' } return session }), + local('session from before the tab bar, a row with no terminal', () => { + const session = twoTabs() + delete session.unifiedTabs + delete session.tabGroups + delete session.tabGroupLayouts + delete session.activeGroupIdByWorktree + session.tabsByWorktree[GIT_KEY]![1]!.ptyId = null + session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = {} + return session + }), local('legacy surface tombstone on a pane still showing its terminal', () => { const session = twoTabs() session.terminalSurfaceTombstonesByPaneKey = {