diff --git a/src/renderer/src/store/slices/terminal-hydration-store-test-bootstrap.ts b/src/renderer/src/store/slices/terminal-hydration-store-test-bootstrap.ts new file mode 100644 index 000000000..ed47faad0 --- /dev/null +++ b/src/renderer/src/store/slices/terminal-hydration-store-test-bootstrap.ts @@ -0,0 +1,21 @@ +import { vi } from 'vitest' + +// Why: import this before the store modules — session hydration reaches for the preload API and the +// runtime/PTY singletons, which don't exist under vitest. +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync: vi.fn() +})) +vi.mock('@/components/terminal-pane/pty-transport', () => ({ + registerEagerPtyBuffer: vi.fn(), + ensurePtyDispatcher: vi.fn() +})) + +const apiProxy = (): unknown => + new Proxy(() => undefined, { + get: (_target, prop) => (prop === 'then' ? undefined : apiProxy()), + apply: () => Promise.resolve(null) + }) + +// @ts-expect-error -- mocked browser preload API +globalThis.window = { api: apiProxy() } diff --git a/src/renderer/src/store/slices/terminal-session-row-hydration.ts b/src/renderer/src/store/slices/terminal-session-row-hydration.ts new file mode 100644 index 000000000..bb728f764 --- /dev/null +++ b/src/renderer/src/store/slices/terminal-session-row-hydration.ts @@ -0,0 +1,270 @@ +import type { + TerminalLayoutSnapshot, + TerminalTab, + WorkspaceSessionState +} from '../../../../shared/types' +import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { clearTransientTerminalState } from './terminal-helpers' + +/** + * A persisted session describes the same terminals twice: as canonical unified tabs and as + * legacy per-worktree terminal rows. Hydration keeps every row that still owns a PTY nothing + * else can reattach to, drops rows the canonical mount fully subsumes, and strips the shared + * PTYs off rows it only partially subsumes. + */ + +type CanonicalTerminals = { + /** Terminal ids the unified tab model owns in this workspace. */ + tabIds: Set + /** PTYs already claimed by canonical rows that survive hydration, keyed to their claimant. */ + tabIdByPtyId: Map + quickCommandLabelByTabId: Map + aiVaultTitleByTabId: Map +} + +export type HydrateWorkspaceTerminalRowsOptions = { + /** + * Rows arrived from a remote snapshot. `unifiedTabs` is not on the remote wire, so the session's + * canonical list still describes the local client and cannot arbitrate ownership of these rows. + */ + rowsFromRemoteSnapshot?: boolean +} + +export type WorkspaceTerminalRowHydration = { + rows: TerminalTab[] + /** PTYs a retained row must give up because a canonical row already owns them. */ + releasedPtyIdsByTabId: Map> + /** + * The PTY a retained row still owns after the release, so reconnect can anchor on it. Hydration + * nulls `tab.ptyId`, and orphan detection ignores layout bindings, so a row that gave up its + * tab-level PTY has no liveness left and the sweep deletes it before its own pane reattaches. + */ + reconnectPtyIdByRetainedTabId: Map + /** Rows dropped as pure canonical duplicates; they are never retired, so callers clean up after them. */ + subsumedTabIds: string[] + /** Rows dropped for an unusable tab id; like subsumed rows they need caller-side cleanup. */ + invalidTabIds: string[] + /** The canonical row that inherited each subsumed row's PTYs, so pointers at it can follow. */ + canonicalTabIdBySubsumedTabId: Map +} + +export function hydrateWorkspaceTerminalRows( + session: WorkspaceSessionState, + worktreeId: string, + rows: readonly TerminalTab[], + options: HydrateWorkspaceTerminalRowsOptions = {} +): WorkspaceTerminalRowHydration { + const canonical = readCanonicalTerminals(session, worktreeId, rows) + const releasedPtyIdsByTabId = new Map>() + const reconnectPtyIdByRetainedTabId = new Map() + const subsumedTabIds: string[] = [] + const invalidTabIds: string[] = [] + const canonicalTabIdBySubsumedTabId = new Map() + const retained: TerminalTab[] = [] + for (const row of rows) { + // Why: old web-client mirrors could persist host surface ids with "::"; makePaneKey reserves ":" as its separator. + if (!isValidTerminalTabId(row.id)) { + invalidTabIds.push(row.id) + continue + } + const claim = options.rowsFromRemoteSnapshot + ? RETAINED_UNCLAIMED + : resolveCanonicalPtyClaim(session, row, canonical) + if (claim.kind === 'subsumed') { + subsumedTabIds.push(row.id) + canonicalTabIdBySubsumedTabId.set(row.id, claim.canonicalTabId) + continue + } + if (claim.releasedPtyIds.size > 0) { + releasedPtyIdsByTabId.set(row.id, claim.releasedPtyIds) + } + if (claim.reconnectPtyId) { + reconnectPtyIdByRetainedTabId.set(row.id, claim.reconnectPtyId) + } + retained.push(row) + } + return { + rows: retained + .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) + .map((row, index) => restoreCanonicalMetadata(row, index, canonical)), + releasedPtyIdsByTabId, + reconnectPtyIdByRetainedTabId, + subsumedTabIds, + invalidTabIds, + canonicalTabIdBySubsumedTabId + } +} + +/** Drop the PTYs a canonical row owns so the retained row records only its own panes. */ +export function releaseTerminalLayoutPtyIds( + layout: TerminalLayoutSnapshot, + releasedPtyIds: ReadonlySet +): TerminalLayoutSnapshot { + const ptyIdsByLeafId = layout.ptyIdsByLeafId + if (!ptyIdsByLeafId) { + return layout + } + const kept = Object.entries(ptyIdsByLeafId).filter(([, ptyId]) => !releasedPtyIds.has(ptyId)) + if (kept.length === Object.keys(ptyIdsByLeafId).length) { + return layout + } + return { ...layout, ptyIdsByLeafId: Object.fromEntries(kept) } +} + +/** Leaves a retained row just lost the PTY for; their panes now cold-start instead of reattaching. */ +export function collectReleasedLeafIds( + layout: TerminalLayoutSnapshot | undefined, + releasedPtyIds: ReadonlySet +): string[] { + return Object.entries(layout?.ptyIdsByLeafId ?? {}) + .filter(([, ptyId]) => releasedPtyIds.has(ptyId)) + .map(([leafId]) => leafId) +} + +function readCanonicalTerminals( + session: WorkspaceSessionState, + worktreeId: string, + rows: readonly TerminalTab[] +): CanonicalTerminals { + const canonicalTabs = (session.unifiedTabs?.[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'terminal' + ) + const tabIds = new Set(canonicalTabs.map((tab) => tab.entityId)) + // Why: only rows that survive the id check can claim PTY ownership; a dropped invalid-id mirror must not evict the valid row sharing its PTY. + const tabIdByPtyId = new Map( + rows + .filter((row) => tabIds.has(row.id) && isValidTerminalTabId(row.id)) + .flatMap((row) => + readPersistedTerminalPtyIds(session, row).claimable.map((ptyId) => [ptyId, row.id] as const) + ) + ) + return { + tabIds, + tabIdByPtyId, + quickCommandLabelByTabId: new Map( + canonicalTabs.flatMap((tab) => + tab.quickCommandLabel?.trim() ? [[tab.entityId, tab.quickCommandLabel.trim()]] : [] + ) + ), + aiVaultTitleByTabId: new Map( + canonicalTabs.flatMap((tab) => (tab.aiVaultTitle ? [[tab.entityId, tab.aiVaultTitle]] : [])) + ) + } +} + +type CanonicalPtyClaim = + | { kind: 'subsumed'; canonicalTabId: string } + | { kind: 'retained'; releasedPtyIds: Set; reconnectPtyId?: string } + +const RETAINED_UNCLAIMED: CanonicalPtyClaim = { kind: 'retained', releasedPtyIds: new Set() } + +/** Canonical mounts win PTY ownership over stale legacy duplicates of the same terminal. */ +function resolveCanonicalPtyClaim( + session: WorkspaceSessionState, + row: TerminalTab, + canonical: CanonicalTerminals +): CanonicalPtyClaim { + if (canonical.tabIds.has(row.id)) { + return RETAINED_UNCLAIMED + } + const { owned, orphaned, mountedByPrimacy } = readPersistedTerminalPtyIds(session, row) + const claimed = owned.filter((ptyId) => canonical.tabIdByPtyId.has(ptyId)) + // Why: an unclaimed row has no canonical twin, which also keeps a PTY-less row — it duplicates + // nothing — out of the fully-claimed branch. + const canonicalTabId = claimed.length > 0 ? canonical.tabIdByPtyId.get(claimed[0]) : undefined + if (canonicalTabId && claimed.length === owned.length) { + return { kind: 'subsumed', canonicalTabId } + } + // Why: a split row with an independent pane owns a PTY nothing else can reattach to, but keeping the + // shared PTY too would leave two recorded owners and make ownership resolution ambiguous (#10486). + // Stale unmounted bindings go too, else reconnect republishes the canonical PTY under this row. + const releasedPtyIds = new Set([ + ...claimed, + ...orphaned.filter((ptyId) => canonical.tabIdByPtyId.has(ptyId)) + ]) + return { + kind: 'retained', + releasedPtyIds, + // Why: the surviving pane becomes this row's primary; reconnect anchors liveness on it so the + // orphan sweep can't delete the row while its tab-level PTY is gone (#10486). Only a row that + // just handed a PTY to a live canonical mount earns this — a leaf binding on its own proves + // nothing and must stay sweepable (e.g. a layout outliving a removed SSH target, #9911). + reconnectPtyId: + releasedPtyIds.size > 0 + ? mountedByPrimacy.find((ptyId) => !releasedPtyIds.has(ptyId)) + : undefined + } +} + +function restoreCanonicalMetadata( + row: TerminalTab, + index: number, + canonical: CanonicalTerminals +): TerminalTab { + const quickCommandLabel = + row.quickCommandLabel?.trim() || canonical.quickCommandLabelByTabId.get(row.id) + const aiVaultTitle = row.aiVaultTitle ?? canonical.aiVaultTitleByTabId.get(row.id) + return { + ...clearTransientTerminalState(row, index), + ...(quickCommandLabel ? { quickCommandLabel } : {}), + ...(aiVaultTitle ? { aiVaultTitle } : {}), + sortOrder: index, + // Why: suppress restored mounts so only real activity updates Recent. + pendingActivationSpawn: true + } +} + +type PersistedTerminalPtyIds = { + /** PTYs a live pane or the tab itself still reattaches to — the only ones that prove ownership. */ + owned: string[] + /** The `owned` PTYs whose mount is unambiguous, so a canonical row may take them off another row. */ + claimable: string[] + /** Live pane PTYs, active leaf first — the order a row picks its primary session from. */ + mountedByPrimacy: string[] + /** PTYs stranded in `ptyIdsByLeafId` by panes that already left the tree; they reattach nothing. */ + orphaned: string[] +} + +function readPersistedTerminalPtyIds( + session: WorkspaceSessionState, + tab: TerminalTab +): PersistedTerminalPtyIds { + const layout = session.terminalLayoutsByTabId[tab.id] + // Why: ptyIdsByLeafId is merged but never pruned, and hydration reads it before + // normalizeTerminalLayoutSnapshot runs, so unmounted leaves still carry dead bindings here. + // Rootless layouts bind their sole pane off-tree, so `owned` treats every entry as mounted. + const mountedLeafIds = layout?.root ? new Set(collectLeafIdsInOrder(layout.root)) : null + const bindings = Object.entries(layout?.ptyIdsByLeafId ?? {}).filter(([, ptyId]) => + Boolean(ptyId) + ) + const mounted: string[] = [] + const unmounted: string[] = [] + for (const [leafId, ptyId] of bindings) { + ;(!mountedLeafIds || mountedLeafIds.has(leafId) ? mounted : unmounted).push(ptyId) + } + const tabLevel = [tab.ptyId, session.remoteSessionIdsByTabId?.[tab.id]].filter( + (ptyId): ptyId is string => Boolean(ptyId) + ) + const owned = new Set([...tabLevel, ...mounted]) + // Why: "sole pane off-tree" is the only rootless shape that proves ownership. A never-pruned map can + // hold more, and claiming those would evict the live row that really owns them (#13098). + const provenLeafId = bindings.length === 1 ? bindings[0]![0] : layout?.activeLeafId + const claimableLeafPtyIds = mountedLeafIds + ? mounted + : bindings.filter(([leafId]) => leafId === provenLeafId).map(([, ptyId]) => ptyId) + const activeLeafPtyId = layout?.activeLeafId + ? layout.ptyIdsByLeafId?.[layout.activeLeafId] + : undefined + const mountedByPrimacy = + activeLeafPtyId && mounted.includes(activeLeafPtyId) + ? [activeLeafPtyId, ...mounted.filter((ptyId) => ptyId !== activeLeafPtyId)] + : mounted + return { + owned: [...owned], + claimable: [...new Set([...tabLevel, ...claimableLeafPtyIds])], + mountedByPrimacy: [...new Set(mountedByPrimacy)], + orphaned: [...new Set(unmounted)].filter((ptyId) => !owned.has(ptyId)) + } +} diff --git a/src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts b/src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts new file mode 100644 index 000000000..ddea9b058 --- /dev/null +++ b/src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts @@ -0,0 +1,376 @@ +// Keep this bare import first: its vi.mock calls run at module eval, and vitest only hoists vi.mock +// inside the test file itself — reordering it below the store imports breaks hydration here. +import './terminal-hydration-store-test-bootstrap' +import { describe, expect, it } from 'vitest' +import { hydrateWorkspaceTerminalRows } from './terminal-session-row-hydration' +import { getOrphanTerminalIds } from './terminal-orphan-helpers' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { Tab, TerminalTab, WorkspaceSessionState } from '../../../../shared/types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { createTestStore, makeLayout, makeTab, makeWorktree, seedStore } from './store-test-helpers' + +const WORKTREE_ID = 'repo1::/wt-1' + +function makeCanonicalUnifiedTab(entityId: string, sortOrder: number): Tab { + return { + id: `unified-${entityId}`, + entityId, + groupId: 'group-1', + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Grok', + customLabel: null, + color: null, + sortOrder, + createdAt: 1 + } +} + +function makeSession(args: { + tabs: TerminalTab[] + layouts: WorkspaceSessionState['terminalLayoutsByTabId'] + remoteSessionIdsByTabId?: Record + canonicalEntityIds: string[] +}): WorkspaceSessionState { + const unifiedTabs = args.canonicalEntityIds.map((entityId, index) => + makeCanonicalUnifiedTab(entityId, index) + ) + return { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo1', + activeWorktreeId: WORKTREE_ID, + activeWorktreeIdsOnShutdown: [WORKTREE_ID], + tabsByWorktree: { [WORKTREE_ID]: args.tabs }, + terminalLayoutsByTabId: args.layouts, + remoteSessionIdsByTabId: args.remoteSessionIdsByTabId, + unifiedTabs: { [WORKTREE_ID]: unifiedTabs }, + tabGroups: { + [WORKTREE_ID]: [ + { + id: 'group-1', + worktreeId: WORKTREE_ID, + activeTabId: unifiedTabs[0]?.id ?? null, + tabOrder: unifiedTabs.map((tab) => tab.id) + } + ] + } + } +} + +function makeSleepingRecord(paneKey: string, tabId: string): SleepingAgentSessionRecord { + return { + paneKey, + tabId, + worktreeId: WORKTREE_ID, + agent: 'claude', + providerSession: { key: 'session_id', id: `session-${paneKey}` }, + prompt: '', + state: 'waiting', + capturedAt: 1, + updatedAt: 1 + } +} + +function hydrate(session: WorkspaceSessionState): ReturnType { + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/wt-1' })] + } + }) + store.getState().hydrateWorkspaceSession(session) + store.getState().hydrateTabsSession(session) + return store +} + +describe('hydrateWorkspaceSession canonical PTY overlap', () => { + it('keeps the valid local row when an invalid-id canonical mirror shares its PTY', () => { + const sharedPtyId = 'daemon-session-1' + const mirrorTabId = 'host-tab::11111111-1111-4111-8111-111111111111' + const session = makeSession({ + tabs: [ + makeTab({ id: mirrorTabId, worktreeId: WORKTREE_ID, ptyId: sharedPtyId }), + makeTab({ id: 'local-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId, sortOrder: 1 }) + ], + layouts: { + [mirrorTabId]: { ...makeLayout(), ptyIdsByLeafId: { 'mirror-leaf': sharedPtyId } }, + 'local-tab': { ...makeLayout(), ptyIdsByLeafId: { 'local-leaf': sharedPtyId } } + }, + canonicalEntityIds: [mirrorTabId] + }) + + const state = hydrate(session).getState() + const persisted = buildWorkspaceSessionPayload(state) + + expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['local-tab']) + expect(state.pendingReconnectPtyIdByTabId['local-tab']).toBe(sharedPtyId) + expect(persisted.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['local-tab']) + expect(persisted.terminalLayoutsByTabId[mirrorTabId]).toBeUndefined() + }) + + it('keeps a legacy split tab whose second pane owns an independent PTY', () => { + const sharedPtyId = 'daemon-shared' + const soloPtyId = 'daemon-solo' + const session = makeSession({ + tabs: [ + makeTab({ id: 'canonical-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId }), + makeTab({ id: 'split-tab', worktreeId: WORKTREE_ID, ptyId: soloPtyId, sortOrder: 1 }) + ], + layouts: { + 'canonical-tab': { ...makeLayout(), ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } }, + 'split-tab': { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': sharedPtyId, 'leaf-b': soloPtyId } + } + }, + canonicalEntityIds: ['canonical-tab'] + }) + + const state = hydrate(session).getState() + const persisted = buildWorkspaceSessionPayload(state) + + expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'split-tab' + ]) + expect(state.pendingReconnectPtyIdByTabId['split-tab']).toBe(soloPtyId) + // Why: the split row keeps its own PTY and gives up the one the canonical row owns. + expect(Object.values(state.terminalLayoutsByTabId['split-tab']?.ptyIdsByLeafId ?? {})).toEqual([ + soloPtyId + ]) + expect(persisted.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'split-tab' + ]) + expect( + Object.values(persisted.terminalLayoutsByTabId['split-tab']?.ptyIdsByLeafId ?? {}) + ).toEqual([soloPtyId]) + }) + + it('ignores a canonical row’s stale leaf binding when scoring another row’s live PTY', () => { + const canonicalPtyId = 'daemon-canonical' + const livePtyId = 'daemon-live' + const session = makeSession({ + tabs: [ + makeTab({ id: 'canonical-tab', worktreeId: WORKTREE_ID, ptyId: canonicalPtyId }), + makeTab({ id: 'live-tab', worktreeId: WORKTREE_ID, ptyId: livePtyId, sortOrder: 1 }) + ], + layouts: { + // 'ghost-leaf' left the tree but its binding was never pruned, so it must not claim livePtyId. + 'canonical-tab': { + root: { type: 'leaf', leafId: 'canonical-leaf' }, + activeLeafId: 'canonical-leaf', + expandedLeafId: null, + ptyIdsByLeafId: { 'canonical-leaf': canonicalPtyId, 'ghost-leaf': livePtyId } + }, + 'live-tab': { ...makeLayout(), ptyIdsByLeafId: { 'live-leaf': livePtyId } } + }, + canonicalEntityIds: ['canonical-tab'] + }) + + const state = hydrate(session).getState() + + expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'live-tab' + ]) + expect(state.pendingReconnectPtyIdByTabId['live-tab']).toBe(livePtyId) + }) + + it('strips a retained row’s stale binding to a PTY the canonical row owns', () => { + const sharedPtyId = 'daemon-shared' + const soloPtyId = 'daemon-solo' + const session = makeSession({ + tabs: [ + makeTab({ id: 'canonical-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId }), + makeTab({ id: 'legacy-tab', worktreeId: WORKTREE_ID, ptyId: soloPtyId, sortOrder: 1 }) + ], + layouts: { + 'canonical-tab': { ...makeLayout(), ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } }, + 'legacy-tab': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': soloPtyId, 'ghost-leaf': sharedPtyId } + } + }, + canonicalEntityIds: ['canonical-tab'] + }) + + const state = hydrate(session).getState() + + expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'legacy-tab' + ]) + // Why: reconnect publishes every recorded leaf PTY, so a leftover binding would re-duplicate ownership. + expect(Object.values(state.terminalLayoutsByTabId['legacy-tab']?.ptyIdsByLeafId ?? {})).toEqual( + [soloPtyId] + ) + }) + + it('ignores a rootless canonical row’s extra bindings when scoring another row’s live PTY', () => { + const canonicalPtyId = 'daemon-canonical' + const livePtyId = 'daemon-live' + const session = makeSession({ + tabs: [ + makeTab({ id: 'canonical-tab', worktreeId: WORKTREE_ID, ptyId: canonicalPtyId }), + makeTab({ id: 'live-tab', worktreeId: WORKTREE_ID, ptyId: livePtyId, sortOrder: 1 }) + ], + layouts: { + // Rootless proves ownership only for a sole off-tree pane; a second never-pruned binding proves nothing. + 'canonical-tab': { + ...makeLayout(), + ptyIdsByLeafId: { 'canonical-leaf': canonicalPtyId, 'ghost-leaf': livePtyId } + }, + 'live-tab': { ...makeLayout(), ptyIdsByLeafId: { 'live-leaf': livePtyId } } + }, + canonicalEntityIds: ['canonical-tab'] + }) + + const state = hydrate(session).getState() + + expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'live-tab' + ]) + expect(state.pendingReconnectPtyIdByTabId['live-tab']).toBe(livePtyId) + }) + + it('advertises a retained row’s own PTY after its tab-level id went to the canonical row', async () => { + const sharedPtyId = 'daemon-shared' + const soloPtyId = 'daemon-solo' + const session = makeSession({ + tabs: [ + makeTab({ id: 'canonical-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId }), + // tab.ptyId is the shared one, so reconnect skips it; soloPtyId lives only in a leaf binding. + makeTab({ id: 'split-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId, sortOrder: 1 }) + ], + layouts: { + 'canonical-tab': { ...makeLayout(), ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } }, + 'split-tab': { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': sharedPtyId, 'leaf-b': soloPtyId } + } + }, + canonicalEntityIds: ['canonical-tab'] + }) + + const store = hydrate(session) + // Why: the row's own pane lives only in a leaf binding, which orphan detection ignores — without + // this anchor the sweep below hard-deletes the row and its session never reattaches. + expect(store.getState().pendingReconnectPtyIdByTabId['split-tab']).toBe(soloPtyId) + expect(getOrphanTerminalIds(store.getState(), WORKTREE_ID).has('split-tab')).toBe(false) + + store.getState().reconcileWorktreeTabModel(WORKTREE_ID) + await store.getState().reconnectPersistedTerminals() + + const state = store.getState() + expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'split-tab' + ]) + // Why: liveness reads ptyIdsByTabId; without this the surviving pane's PTY reads as an orphan until mount. + expect(state.ptyIdsByTabId['split-tab']).toEqual([soloPtyId]) + }) + + it('drops a sleeping agent record on the leaf whose PTY moved to the canonical row', () => { + const sharedPtyId = 'daemon-shared' + const soloPtyId = 'daemon-solo' + const sharedLeafId = '11111111-1111-4111-8111-111111111111' + const soloLeafId = '22222222-2222-4222-8222-222222222222' + const session = { + ...makeSession({ + tabs: [ + makeTab({ id: 'canonical-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId }), + makeTab({ id: 'split-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId, sortOrder: 1 }) + ], + layouts: { + 'canonical-tab': { ...makeLayout(), ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } }, + 'split-tab': { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: sharedLeafId }, + second: { type: 'leaf' as const, leafId: soloLeafId } + }, + activeLeafId: sharedLeafId, + expandedLeafId: null, + ptyIdsByLeafId: { [sharedLeafId]: sharedPtyId, [soloLeafId]: soloPtyId } + } + }, + canonicalEntityIds: ['canonical-tab'] + }), + sleepingAgentSessionsByPaneKey: { + [`split-tab:${sharedLeafId}`]: makeSleepingRecord(`split-tab:${sharedLeafId}`, 'split-tab'), + [`split-tab:${soloLeafId}`]: makeSleepingRecord(`split-tab:${soloLeafId}`, 'split-tab') + } + } + + const state = hydrate(session).getState() + + expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual([`split-tab:${soloLeafId}`]) + }) + + it('lets remote-snapshot rows keep every PTY, since unifiedTabs describes the local client', () => { + const sharedPtyId = 'daemon-shared' + const session = makeSession({ + tabs: [ + makeTab({ id: 'canonical-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId }), + makeTab({ id: 'remote-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId, sortOrder: 1 }) + ], + layouts: { + 'canonical-tab': { ...makeLayout(), ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } }, + 'remote-tab': { ...makeLayout(), ptyIdsByLeafId: { 'remote-leaf': sharedPtyId } } + }, + canonicalEntityIds: ['canonical-tab'] + }) + const rows = session.tabsByWorktree[WORKTREE_ID]! + + expect( + hydrateWorkspaceTerminalRows(session, WORKTREE_ID, rows, { rowsFromRemoteSnapshot: true }) + .subsumedTabIds + ).toEqual([]) + // Guards the gate: without the flag the same rows collapse, so a caller that forgets it drops live remote rows. + expect(hydrateWorkspaceTerminalRows(session, WORKTREE_ID, rows).subsumedTabIds).toEqual([ + 'remote-tab' + ]) + }) + + it('retains a non-canonical row that owns no PTY at all', () => { + const sharedPtyId = 'daemon-shared' + const session = makeSession({ + tabs: [ + makeTab({ id: 'canonical-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId }), + makeTab({ id: 'empty-tab', worktreeId: WORKTREE_ID, ptyId: null, sortOrder: 1 }) + ], + layouts: { + 'canonical-tab': { ...makeLayout(), ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } }, + 'empty-tab': makeLayout() + }, + canonicalEntityIds: ['canonical-tab'] + }) + + const state = hydrate(session).getState() + + expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'empty-tab' + ]) + }) +}) diff --git a/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts b/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts new file mode 100644 index 000000000..6a559c9e1 --- /dev/null +++ b/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts @@ -0,0 +1,201 @@ +import './terminal-hydration-store-test-bootstrap' +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { WorkspaceSessionState } from '../../../../shared/types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { createTestStore, makeLayout, makeTab, makeWorktree, seedStore } from './store-test-helpers' + +describe('hydrateWorkspaceSession canonical terminal rows', () => { + it('drops only legacy rows that duplicate canonical PTY ownership', () => { + const store = createTestStore() + const worktreeId = 'repo1::/wt-1' + const sharedPtyId = 'daemon-session-1' + const recoveryPtyId = 'daemon-session-2' + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })] + } + }) + + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo1', + activeWorktreeId: worktreeId, + activeTabId: 'stale-tab', + activeWorktreeIdsOnShutdown: [worktreeId], + activeTabIdByWorktree: { [worktreeId]: 'stale-tab' }, + tabsByWorktree: { + [worktreeId]: [ + makeTab({ id: 'canonical-tab', worktreeId, ptyId: sharedPtyId }), + makeTab({ id: 'stale-tab', worktreeId, ptyId: sharedPtyId }), + makeTab({ id: 'recovery-tab', worktreeId, ptyId: recoveryPtyId }) + ] + }, + terminalLayoutsByTabId: { + 'canonical-tab': { + ...makeLayout(), + ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } + }, + 'stale-tab': { + ...makeLayout(), + ptyIdsByLeafId: { 'stale-leaf': sharedPtyId } + }, + 'recovery-tab': { + ...makeLayout(), + ptyIdsByLeafId: { 'recovery-leaf': recoveryPtyId } + } + }, + remoteSessionIdsByTabId: { + 'canonical-tab': sharedPtyId, + 'stale-tab': sharedPtyId, + 'recovery-tab': recoveryPtyId + }, + unifiedTabs: { + [worktreeId]: [ + { + id: 'canonical-unified-tab', + entityId: 'canonical-tab', + groupId: 'group-1', + worktreeId, + contentType: 'terminal', + label: 'Grok', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + tabGroups: { + [worktreeId]: [ + { + id: 'group-1', + worktreeId, + activeTabId: 'canonical-unified-tab', + tabOrder: ['canonical-unified-tab'] + } + ] + } + } + + store.getState().hydrateWorkspaceSession(session) + store.getState().hydrateTabsSession(session) + const reconciliation = store.getState().reconcileWorktreeTabModel(worktreeId) + const state = store.getState() + const persisted = buildWorkspaceSessionPayload(state) + + expect(reconciliation.renderableTabCount).toBe(2) + expect(state.unifiedTabsByWorktree[worktreeId]?.map((tab) => tab.entityId)).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(state.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(state.terminalLayoutsByTabId['stale-tab']).toBeUndefined() + expect(persisted.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(persisted.terminalLayoutsByTabId['stale-tab']).toBeUndefined() + expect(state.pendingReconnectTabByWorktree[worktreeId]).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(state.pendingReconnectPtyIdByTabId).toEqual({ + 'canonical-tab': sharedPtyId, + 'recovery-tab': recoveryPtyId + }) + // The canonical row inherited the dropped row's PTY, so focus follows it instead of resetting. + expect(state.activeTabId).toBe('canonical-tab') + expect(state.activeTabIdByWorktree).toEqual({ [worktreeId]: 'canonical-tab' }) + }) + + it('clears sleeping-agent records for subsumed and invalid-id rows only', () => { + const store = createTestStore() + const worktreeId = 'repo1::/wt-1' + const sharedPtyId = 'daemon-session-1' + const recoveryPtyId = 'daemon-session-2' + const invalidTabId = 'host-tab::11111111-1111-4111-8111-111111111111' + const leafId = '22222222-2222-4222-8222-222222222222' + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })] + } + }) + const makeSleepingRecord = (tabId: string): SleepingAgentSessionRecord => ({ + paneKey: `${tabId}:${leafId}`, + tabId, + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${tabId}` }, + prompt: 'continue', + state: 'working', + capturedAt: 1, + updatedAt: 1 + }) + + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo1', + activeWorktreeId: worktreeId, + activeWorktreeIdsOnShutdown: [worktreeId], + tabsByWorktree: { + [worktreeId]: [ + makeTab({ id: 'canonical-tab', worktreeId, ptyId: sharedPtyId }), + makeTab({ id: 'stale-tab', worktreeId, ptyId: sharedPtyId }), + makeTab({ id: invalidTabId, worktreeId, ptyId: null }), + makeTab({ id: 'recovery-tab', worktreeId, ptyId: recoveryPtyId }) + ] + }, + terminalLayoutsByTabId: { + 'canonical-tab': { ...makeLayout(), ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } }, + 'stale-tab': { ...makeLayout(), ptyIdsByLeafId: { 'stale-leaf': sharedPtyId } }, + 'recovery-tab': { ...makeLayout(), ptyIdsByLeafId: { 'recovery-leaf': recoveryPtyId } } + }, + sleepingAgentSessionsByPaneKey: { + [`stale-tab:${leafId}`]: makeSleepingRecord('stale-tab'), + [`${invalidTabId}:${leafId}`]: makeSleepingRecord(invalidTabId), + [`recovery-tab:${leafId}`]: makeSleepingRecord('recovery-tab') + }, + unifiedTabs: { + [worktreeId]: [ + { + id: 'canonical-unified-tab', + entityId: 'canonical-tab', + groupId: 'group-1', + worktreeId, + contentType: 'terminal', + label: 'Grok', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + tabGroups: { + [worktreeId]: [ + { + id: 'group-1', + worktreeId, + activeTabId: 'canonical-unified-tab', + tabOrder: ['canonical-unified-tab'] + } + ] + } + } + + store.getState().hydrateWorkspaceSession(session) + const state = store.getState() + + expect(state.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + // Why: both dropped classes keep a valid worktreeId, so only the per-tab sweep can evict them. + expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual([`recovery-tab:${leafId}`]) + }) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 7167e1cfc..3eb700950 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -36,7 +36,7 @@ import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' -import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' import { buildByIdIndex, buildWorktreeByIdIndex } from './worktree-by-id-index' import { resolveActiveTabOwnerWorktreeId } from './active-tab-owner-worktree' import { isSameCodexRestartNoticeAccount } from './codex-restart-notice-account-identity' @@ -58,6 +58,11 @@ import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' +import { + collectReleasedLeafIds, + hydrateWorkspaceTerminalRows, + releaseTerminalLayoutPtyIds +} from './terminal-session-row-hydration' import { getRecentlyClosedTabPosition, pushClosedTerminalTabSnapshot, @@ -972,6 +977,10 @@ function targetScopedWorkspaceHydrationPatch( continue } for (const tab of session.tabsByWorktree[workspaceKey] ?? []) { + // Why: rows hydration dropped (invalid id, canonical duplicate) would leak reconnect keys nothing owns. + if (!retainedTargetTabIds.has(tab.id)) { + continue + } const ptyId = session.remoteSessionIdsByTabId?.[tab.id] ?? tab.ptyId if (ptyId && parseAppSshPtyId(ptyId)?.connectionId === authority.targetId) { pendingReconnectPtyIdByTabId[tab.id] = ptyId @@ -3938,56 +3947,78 @@ export const createTerminalSlice: StateCreator validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - // Why: suppress restored mounts so only real activity updates Recent. - const tabsByWorktree: Record = Object.fromEntries( - Object.entries(session.tabsByWorktree) - .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) - .map(([worktreeId, tabs]) => { - const quickCommandLabelByTerminalId = new Map( - (session.unifiedTabs?.[worktreeId] ?? []) - .filter((tab) => tab.contentType === 'terminal' && tab.quickCommandLabel?.trim()) - .map((tab) => [tab.entityId, tab.quickCommandLabel!.trim()]) - ) - const aiVaultTitleByTerminalId = new Map( - (session.unifiedTabs?.[worktreeId] ?? []) - .filter((tab) => tab.contentType === 'terminal' && tab.aiVaultTitle) - .map((tab) => [tab.entityId, tab.aiVaultTitle!]) - ) - return [ + // Why: rows for these keys came off the remote wire, which carries no unifiedTabs, so the + // session's canonical list describes a different snapshot and must not arbitrate their PTYs. + const remoteSnapshotWorkspaceKeys = new Set( + options?.directSshAuthority ? (options.replaceWorkspaceKeys ?? []) : [] + ) + const rowHydrationByWorktree = Object.entries(session.tabsByWorktree) + .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) + .map( + ([worktreeId, tabs]) => + [ worktreeId, - [...tabs] - .filter((tab) => { - // Why: old web-client mirrors could persist host surface ids with "::"; makePaneKey reserves ":" as its separator. - return isValidTerminalTabId(tab.id) - }) - .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) - .map((tab, index) => { - const quickCommandLabel = - tab.quickCommandLabel?.trim() || quickCommandLabelByTerminalId.get(tab.id) - const aiVaultTitle = tab.aiVaultTitle ?? aiVaultTitleByTerminalId.get(tab.id) - return { - ...clearTransientTerminalState(tab, index), - ...(quickCommandLabel ? { quickCommandLabel } : {}), - ...(aiVaultTitle ? { aiVaultTitle } : {}), - sortOrder: index, - pendingActivationSpawn: true - } - }) - ] - }) + hydrateWorkspaceTerminalRows(session, worktreeId, tabs, { + rowsFromRemoteSnapshot: remoteSnapshotWorkspaceKeys.has(worktreeId) + }) + ] as const + ) + const tabsByWorktree: Record = Object.fromEntries( + rowHydrationByWorktree + .map(([worktreeId, hydration]) => [worktreeId, hydration.rows] as const) .filter(([, tabs]) => tabs.length > 0) ) + const releasedPtyIdsByTabId = new Map>( + rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.releasedPtyIdsByTabId]) + ) + const reconnectPtyIdByRetainedTabId = new Map( + rowHydrationByWorktree.flatMap(([, hydration]) => [ + ...hydration.reconnectPtyIdByRetainedTabId + ]) + ) + const canonicalTabIdBySubsumedTabId = new Map( + rowHydrationByWorktree.flatMap(([, hydration]) => [ + ...hydration.canonicalTabIdBySubsumedTabId + ]) + ) const validTabIds = new Set( Object.values(tabsByWorktree) .flat() .map((tab) => tab.id) ) - const sleepingAgentSessionsByPaneKey = Object.fromEntries( + let sleepingAgentSessionsByPaneKey = Object.fromEntries( Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter(([, record]) => validWorktreeIds.has(record.worktreeId) ) ) + // Why: a dropped row is never retired, so nothing else clears the sleeping records keyed to + // it — its worktree is still valid — and the workspace keeps a pane nobody can wake. + for (const [, hydration] of rowHydrationByWorktree) { + for (const tabId of [...hydration.subsumedTabIds, ...hydration.invalidTabIds]) { + sleepingAgentSessionsByPaneKey = removeSleepingAgentSessionsForTab( + sleepingAgentSessionsByPaneKey, + tabId + ) + } + } + // Why: a released leaf's agent session belongs to the canonical row now; leaving the record here + // would cold-restore the same provider session on a pane that no longer owns its PTY. + const releasedPaneKeys = new Set( + [...releasedPtyIdsByTabId].flatMap(([tabId, releasedPtyIds]) => + collectReleasedLeafIds(session.terminalLayoutsByTabId[tabId], releasedPtyIds) + // Why: persisted ptyIdsByLeafId keys are unvalidated strings, and makePaneKey throws on non-UUIDs. + .filter(isTerminalLeafId) + .map((leafId) => makePaneKey(tabId, leafId)) + ) + ) + if (releasedPaneKeys.size > 0) { + sleepingAgentSessionsByPaneKey = Object.fromEntries( + Object.entries(sleepingAgentSessionsByPaneKey).filter( + ([paneKey]) => !releasedPaneKeys.has(paneKey) + ) + ) + } const fallbackActiveWorktreeId = !session.activeWorktreeId && session.activeRepoId && knownRepoIds.has(session.activeRepoId) ? (runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.find( @@ -4015,8 +4046,13 @@ export const createTerminalSlice: StateCreator activeWorkspaceKey && session.activeWorkspaceExecutionHostId ? session.activeWorkspaceExecutionHostId : null + // Why: follow a subsumed row to the canonical twin that inherited its PTY, else the app + // restarts with no active terminal even though the same session is still mounted. + const restoredActiveTabId = session.activeTabId + ? (canonicalTabIdBySubsumedTabId.get(session.activeTabId) ?? session.activeTabId) + : null const activeTabId = - session.activeTabId && validTabIds.has(session.activeTabId) ? session.activeTabId : null + restoredActiveTabId && validTabIds.has(restoredActiveTabId) ? restoredActiveTabId : null const activeRepoId = session.activeRepoId && runtimeSessionPlaceholders.repos.some((repo) => repo.id === session.activeRepoId) @@ -4039,7 +4075,12 @@ export const createTerminalSlice: StateCreator for (const worktreeId of pendingReconnectWorktreeIds) { const rawTabs = session.tabsByWorktree[worktreeId] ?? [] const liveTabIds = rawTabs - .filter((t) => (t.ptyId || remoteSessionIds[t.id]) && validTabIds.has(t.id)) + .filter( + (t) => + // Why: a row that gave its tab-level PTY to the canonical twin still owns leaf sessions to advertise. + (t.ptyId || remoteSessionIds[t.id] || reconnectPtyIdByRetainedTabId.has(t.id)) && + validTabIds.has(t.id) + ) .map((t) => t.id) if (liveTabIds.length > 0) { pendingReconnectTabByWorktree[worktreeId] = liveTabIds @@ -4060,7 +4101,12 @@ export const createTerminalSlice: StateCreator } const rawTabs = session.tabsByWorktree[worktreeId] ?? [] for (const tab of rawTabs) { - if (tab.ptyId && validTabIds.has(tab.id)) { + // Why: a released PTY belongs to the canonical row now; reattaching it here would restore the duplicate ownership. + if ( + tab.ptyId && + validTabIds.has(tab.id) && + !releasedPtyIdsByTabId.get(tab.id)?.has(tab.ptyId) + ) { pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId } } @@ -4068,17 +4114,32 @@ export const createTerminalSlice: StateCreator // Why: remote PTY reattach uses the relay's pty.attach RPC, not the local daemon; the loop above skips SSH repos, so no overlap. for (const [tabId, sessionId] of Object.entries(remoteSessionIds)) { - if (validTabIds.has(tabId)) { + if (validTabIds.has(tabId) && !releasedPtyIdsByTabId.get(tabId)?.has(sessionId)) { pendingReconnectPtyIdByTabId[tabId] = sessionId } } + // Why: hydration nulls tab.ptyId and orphan detection ignores layout bindings, so a row whose + // tab-level PTY went to its canonical twin reads as dead and is swept before its own surviving + // pane can reattach. Anchor it on the PTY it still owns (#10486). + for (const [tabId, ptyId] of reconnectPtyIdByRetainedTabId) { + if (validTabIds.has(tabId) && !pendingReconnectPtyIdByTabId[tabId]) { + pendingReconnectPtyIdByTabId[tabId] = ptyId + } + } + // Restore per-worktree active tab; validate ids when the map exists, else derive for legacy sessions. let activeTabIdByWorktree: Record = {} if (session.activeTabIdByWorktree) { for (const [wId, tabId] of Object.entries(session.activeTabIdByWorktree)) { - if (validWorktreeIds.has(wId) && tabId && validTabIds.has(tabId)) { - activeTabIdByWorktree[wId] = tabId + if (!validWorktreeIds.has(wId) || !tabId) { + continue + } + // Why: a subsumed row's canonical twin holds the same PTY, so follow the pointer there + // instead of forgetting which terminal the workspace last focused. + const restored = validTabIds.has(tabId) ? tabId : canonicalTabIdBySubsumedTabId.get(tabId) + if (restored && validTabIds.has(restored)) { + activeTabIdByWorktree[wId] = restored } } } else { @@ -4177,7 +4238,11 @@ export const createTerminalSlice: StateCreator terminalLayoutsByTabId: Object.fromEntries( Object.entries(session.terminalLayoutsByTabId) .filter(([tabId]) => validTabIds.has(tabId)) - .map(([tabId, layout]) => { + .map(([tabId, persisted]) => { + const releasedPtyIds = releasedPtyIdsByTabId.get(tabId) + const layout = releasedPtyIds + ? releaseTerminalLayoutPtyIds(persisted, releasedPtyIds) + : persisted // Why: old sessions can contain renderer-local pane:1-style leaf ids; normalize before runtime/mobile surfaces read them. const normalization = normalizeTerminalLayoutSnapshot(layout) const normalized = normalization.snapshot @@ -4291,23 +4356,27 @@ export const createTerminalSlice: StateCreator console.debug( `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` ) + // Why: populate ptyIdsByTabId so the sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. + // A row whose tab.ptyId went to the canonical row has no tab-level id left, but its own leaf PTYs still need advertising. + const allPtyIds = hasLeafMappings + ? (Object.values(leafPtyMap).filter(Boolean) as string[]) + : tabLevelPtyId + ? [tabLevelPtyId] + : [] + if (allPtyIds.length > 0) { + // Why: hide-sleeping reads ptyIdsByTabId for liveness; restored daemon sessions run before their pane remounts, so advertise them. + reconnectedPtyIdsByTabId ??= { ...ptyIdsByTabId } + reconnectedPtyIdsByTabId[tabId] = allPtyIds + } if (tabLevelPtyId) { reconnectedTabsByWorktree ??= { ...tabsByWorktree } const nextTabs = reconnectedTabsByWorktree[worktreeId] if (!nextTabs) { continue } - - // Why: populate ptyIdsByTabId so the sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. - const allPtyIds = hasLeafMappings - ? (Object.values(leafPtyMap).filter(Boolean) as string[]) - : [tabLevelPtyId] reconnectedTabsByWorktree[worktreeId] = nextTabs.map((t) => t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t ) - // Why: hide-sleeping reads ptyIdsByTabId for liveness; restored daemon sessions run before their pane remounts, so advertise them. - reconnectedPtyIdsByTabId ??= { ...ptyIdsByTabId } - reconnectedPtyIdsByTabId[tabId] = allPtyIds } } }