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 bac4a3a57f9..87563472922 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 @@ -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({}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 8eee6715242..87729c71ddb 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -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 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 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) ) })