From 539249bfa0718847e23511f57fafe6cf56f233c8 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:00:35 -0700 Subject: [PATCH] fix(terminal): deduplicate rows with correct orphaned PTY handling - Extract row hydration to terminal-session-row-hydration.ts - Distinguish mounted vs orphaned PTY bindings when scoring ownership - Strip shared PTYs from retained rows to prevent duplicate reconnection - Follow dropped row focus to canonical twin; clean up orphaned agent sessions --- .../slices/terminal-session-row-hydration.ts | 217 ++++++++++++++++++ ...ls-hydration-canonical-pty-overlap.test.ts | 62 +++++ ...terminals-hydration-canonical-rows.test.ts | 5 +- src/renderer/src/store/slices/terminals.ts | 144 ++++++------ 4 files changed, 350 insertions(+), 78 deletions(-) create mode 100644 src/renderer/src/store/slices/terminal-session-row-hydration.ts 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 00000000000..7d4aa6d0e3a --- /dev/null +++ b/src/renderer/src/store/slices/terminal-session-row-hydration.ts @@ -0,0 +1,217 @@ +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> + /** Rows dropped as pure canonical duplicates; they are never retired, so callers clean up after them. */ + subsumedTabIds: 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 subsumedTabIds: 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)) { + 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) + } + 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, + subsumedTabIds, + 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) } +} + +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).owned.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 } + +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 } = 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. + return { + kind: 'retained', + releasedPtyIds: new Set([ + ...claimed, + ...orphaned.filter((ptyId) => canonical.tabIdByPtyId.has(ptyId)) + ]) + } +} + +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[] + /** 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 treat every entry as mounted. + const mountedLeafIds = layout?.root ? new Set(collectLeafIdsInOrder(layout.root)) : null + const mounted: string[] = [] + const unmounted: string[] = [] + for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) { + if (!ptyId) { + continue + } + ;(!mountedLeafIds || mountedLeafIds.has(leafId) ? mounted : unmounted).push(ptyId) + } + const owned = new Set( + [tab.ptyId, session.remoteSessionIdsByTabId?.[tab.id], ...mounted].filter( + (ptyId): ptyId is string => Boolean(ptyId) + ) + ) + return { + owned: [...owned], + 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 index 30813e39d26..6264733b9d7 100644 --- 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 @@ -152,6 +152,68 @@ describe('hydrateWorkspaceSession canonical PTY overlap', () => { ).toContain(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('retains a non-canonical row that owns no PTY at all', () => { const sharedPtyId = 'daemon-shared' const session = makeSession({ 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 index 87563472922..fb27f4385f1 100644 --- a/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts @@ -124,7 +124,8 @@ describe('hydrateWorkspaceSession canonical terminal rows', () => { 'canonical-tab': sharedPtyId, 'recovery-tab': recoveryPtyId }) - expect(state.activeTabId).toBeNull() - expect(state.activeTabIdByWorktree).toEqual({}) + // 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' }) }) }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 50b70962843..8c6cadecd84 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,10 @@ 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 { + hydrateWorkspaceTerminalRows, + releaseTerminalLayoutPtyIds +} from './terminal-session-row-hydration' import { getRecentlyClosedTabPosition, pushClosedTerminalTabSnapshot, @@ -492,17 +496,6 @@ function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] } -function collectPersistedTerminalPtyIds( - session: WorkspaceSessionState, - tab: TerminalTab -): string[] { - return uniquePtyIds([ - tab.ptyId, - session.remoteSessionIdsByTabId?.[tab.id], - ...Object.values(session.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}) - ]) -} - function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined @@ -3949,77 +3942,56 @@ 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 canonicalTerminalIds = new Set( - (session.unifiedTabs?.[worktreeId] ?? []).flatMap((tab) => - tab.contentType === 'terminal' ? [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 canonicalPtyIds = new Set( - tabs - .filter((tab) => canonicalTerminalIds.has(tab.id) && isValidTerminalTabId(tab.id)) - .flatMap((tab) => collectPersistedTerminalPtyIds(session, tab)) - ) - 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: canonical mounts win PTY ownership over stale legacy duplicates. - // Why: old web-client mirrors could persist host surface ids with "::"; makePaneKey reserves ":" as its separator. - const persistedPtyIds = collectPersistedTerminalPtyIds(session, tab) - // Why: drop only rows the canonical mount fully subsumes; a split tab with one independent pane still owns a PTY nothing else can reattach. - // Why: the length guard keeps `every` from swallowing PTY-less rows, which own nothing to duplicate. - const isPureDuplicate = - persistedPtyIds.length > 0 && - persistedPtyIds.every((ptyId) => canonicalPtyIds.has(ptyId)) - return ( - (canonicalTerminalIds.has(tab.id) || !isPureDuplicate) && - 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 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 subsumed row is dropped rather than retired, so nothing else clears the sleeping + // records keyed to it and the workspace keeps a pane nobody can wake. + for (const [, hydration] of rowHydrationByWorktree) { + for (const tabId of hydration.subsumedTabIds) { + sleepingAgentSessionsByPaneKey = removeSleepingAgentSessionsForTab( + sleepingAgentSessionsByPaneKey, + tabId + ) + } + } const fallbackActiveWorktreeId = !session.activeWorktreeId && session.activeRepoId && knownRepoIds.has(session.activeRepoId) ? (runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.find( @@ -4047,8 +4019,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) @@ -4092,7 +4069,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 } } @@ -4100,7 +4082,7 @@ 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 } } @@ -4109,8 +4091,14 @@ export const createTerminalSlice: StateCreator 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 { @@ -4209,7 +4197,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