fix(terminal): preserve independent legacy PTY rows

This commit is contained in:
Jinjing
2026-08-08 17:38:42 -07:00
parent 032466518f
commit ddebefef34
2 changed files with 56 additions and 18 deletions
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
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'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
@@ -22,10 +23,11 @@ const apiProxy = (): unknown =>
globalThis.window = { api: apiProxy() }
describe('hydrateWorkspaceSession canonical terminal rows', () => {
it('restores only canonical terminal rows from unified sessions', () => {
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' })]
@@ -42,7 +44,8 @@ describe('hydrateWorkspaceSession canonical terminal rows', () => {
tabsByWorktree: {
[worktreeId]: [
makeTab({ id: 'canonical-tab', worktreeId, ptyId: sharedPtyId }),
makeTab({ id: 'stale-tab', worktreeId, ptyId: sharedPtyId })
makeTab({ id: 'stale-tab', worktreeId, ptyId: sharedPtyId }),
makeTab({ id: 'recovery-tab', worktreeId, ptyId: recoveryPtyId })
]
},
terminalLayoutsByTabId: {
@@ -53,11 +56,16 @@ describe('hydrateWorkspaceSession canonical terminal rows', () => {
'stale-tab': {
...makeLayout(),
ptyIdsByLeafId: { 'stale-leaf': sharedPtyId }
},
'recovery-tab': {
...makeLayout(),
ptyIdsByLeafId: { 'recovery-leaf': recoveryPtyId }
}
},
remoteSessionIdsByTabId: {
'canonical-tab': sharedPtyId,
'stale-tab': sharedPtyId
'stale-tab': sharedPtyId,
'recovery-tab': recoveryPtyId
},
unifiedTabs: {
[worktreeId]: [
@@ -91,16 +99,30 @@ describe('hydrateWorkspaceSession canonical terminal rows', () => {
store.getState().hydrateTabsSession(session)
const reconciliation = store.getState().reconcileWorktreeTabModel(worktreeId)
const state = store.getState()
const persisted = buildWorkspaceSessionPayload(state)
expect(reconciliation.renderableTabCount).toBe(1)
expect(reconciliation.renderableTabCount).toBe(2)
expect(state.unifiedTabsByWorktree[worktreeId]?.map((tab) => tab.entityId)).toEqual([
'canonical-tab'
'canonical-tab',
'recovery-tab'
])
expect(state.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([
'canonical-tab',
'recovery-tab'
])
expect(state.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['canonical-tab'])
expect(state.terminalLayoutsByTabId['stale-tab']).toBeUndefined()
expect(state.pendingReconnectTabByWorktree[worktreeId]).toEqual(['canonical-tab'])
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
'canonical-tab': sharedPtyId,
'recovery-tab': recoveryPtyId
})
expect(state.activeTabId).toBeNull()
expect(state.activeTabIdByWorktree).toEqual({})
+26 -10
View File
@@ -492,6 +492,17 @@ 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
@@ -3943,14 +3954,16 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
Object.entries(session.tabsByWorktree)
.filter(([worktreeId]) => validWorktreeIds.has(worktreeId))
.map(([worktreeId, tabs]) => {
const canonicalTerminalIds =
session.unifiedTabs && session.tabGroups
? new Set(
(session.unifiedTabs[worktreeId] ?? []).flatMap((tab) =>
tab.contentType === 'terminal' ? [tab.entityId] : []
)
)
: null
const canonicalTerminalIds = new Set(
(session.unifiedTabs?.[worktreeId] ?? []).flatMap((tab) =>
tab.contentType === 'terminal' ? [tab.entityId] : []
)
)
const canonicalPtyIds = new Set(
tabs
.filter((tab) => canonicalTerminalIds.has(tab.id))
.flatMap((tab) => collectPersistedTerminalPtyIds(session, tab))
)
const quickCommandLabelByTerminalId = new Map(
(session.unifiedTabs?.[worktreeId] ?? [])
.filter((tab) => tab.contentType === 'terminal' && tab.quickCommandLabel?.trim())
@@ -3965,10 +3978,13 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
worktreeId,
[...tabs]
.filter((tab) => {
// Why: canonical sessions use legacy rows only as terminal backing data; noncanonical rows are closed tabs, not migration candidates.
// 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.
return (
(!canonicalTerminalIds || canonicalTerminalIds.has(tab.id)) &&
(canonicalTerminalIds.has(tab.id) ||
!collectPersistedTerminalPtyIds(session, tab).some((ptyId) =>
canonicalPtyIds.has(ptyId)
)) &&
isValidTerminalTabId(tab.id)
)
})