Reland canonical PTY hydration without evicting live legacy rows (#13132)

* fix(terminal): restore canonical terminal rows from unified sessions

During session hydration, non-canonical terminal rows (closed/stale tabs)
were being restored alongside canonical ones. Filter to only canonical
terminal rows from unified sessions, treating legacy rows as terminal
backing data only.

* fix(terminal): preserve independent legacy PTY rows

* fix(terminal): stop canonical PTY filter from evicting live legacy rows

Two regressions in the hydrateWorkspaceSession canonical-PTY filter:

1. canonicalPtyIds was built before isValidTerminalTabId ran, so a web-client
   mirror row with a "::" id seeded its PTY into the set and then evicted the
   valid local row owning the same PTY - wiping every terminal in the worktree
   and orphaning the daemon PTY, persisted back to disk on the next write.

2. `.some()` dropped an entire legacy split tab for a single overlapping leaf,
   discarding panes whose PTYs nothing else referenced.

Only rows that survive the id check now claim ownership, and only pure
duplicates (every PTY already canonical, guarded against PTY-less rows) drop.

* 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

* fix(terminal): clear sleeping-agent records for invalid-tab-id rows

Rows with invalid tab IDs are dropped like subsumed rows but were not getting their sleeping-agent records cleaned up. Extract common test mocks into a bootstrap file to reduce duplication.

* fix(terminal): anchor retained rows on remaining leaf PTYs

When a canonical row claims a retained row's tab-level PTY, the row
still owns leaf-level PTYs in split panes. Anchor on the surviving
leaf PTY so the orphan sweep doesn't delete the row before its pane
reattaches (#10486). Also cleans up sleeping agent sessions for
released leaves and improves PTY categorization.

* persisted ptyIdsByLeafId keys are unvalidated strings, and makePaneKey throws on non-UUIDs.
This commit is contained in:
Jinjing 2026-08-09 14:50:46 -07:00 committed by GitHub
parent 63af0bdd24
commit 68b6e9f70d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 990 additions and 53 deletions

View File

@ -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() }

View File

@ -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<string>
/** PTYs already claimed by canonical rows that survive hydration, keyed to their claimant. */
tabIdByPtyId: Map<string, string>
quickCommandLabelByTabId: Map<string, string>
aiVaultTitleByTabId: Map<string, AiVaultSessionTitle>
}
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<string, Set<string>>
/**
* 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<string, string>
/** 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<string, string>
}
export function hydrateWorkspaceTerminalRows(
session: WorkspaceSessionState,
worktreeId: string,
rows: readonly TerminalTab[],
options: HydrateWorkspaceTerminalRowsOptions = {}
): WorkspaceTerminalRowHydration {
const canonical = readCanonicalTerminals(session, worktreeId, rows)
const releasedPtyIdsByTabId = new Map<string, Set<string>>()
const reconnectPtyIdByRetainedTabId = new Map<string, string>()
const subsumedTabIds: string[] = []
const invalidTabIds: string[] = []
const canonicalTabIdBySubsumedTabId = new Map<string, string>()
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<string>
): 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>
): 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<string>; 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))
}
}

View File

@ -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<string, string>
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<typeof createTestStore> {
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 rows stale leaf binding when scoring another rows 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 rows 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 rows extra bindings when scoring another rows 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 rows 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'
])
})
})

View File

@ -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}`])
})
})

View File

@ -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<AppState, [], [], TerminalSlice>
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
// Why: suppress restored mounts so only real activity updates Recent.
const tabsByWorktree: Record<string, TerminalTab[]> = 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<string, TerminalTab[]> = Object.fromEntries(
rowHydrationByWorktree
.map(([worktreeId, hydration]) => [worktreeId, hydration.rows] as const)
.filter(([, tabs]) => tabs.length > 0)
)
const releasedPtyIdsByTabId = new Map<string, Set<string>>(
rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.releasedPtyIdsByTabId])
)
const reconnectPtyIdByRetainedTabId = new Map<string, string>(
rowHydrationByWorktree.flatMap(([, hydration]) => [
...hydration.reconnectPtyIdByRetainedTabId
])
)
const canonicalTabIdBySubsumedTabId = new Map<string, string>(
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<string>(
[...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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
}
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<AppState, [], [], TerminalSlice>
// 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<string, string | null> = {}
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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
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
}
}
}