From f27a7d8a38e40eb28754eda434cf3c826276163b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:12:24 -0700 Subject: [PATCH] fix(session): preserve chrome after degraded repo scan --- src/renderer/src/store/slices/browser.ts | 15 +- .../slices/degraded-repo-hydration.test.ts | 161 ++++++++++++++++++ .../slices/degraded-repo-worktree-validity.ts | 51 ++++++ src/renderer/src/store/slices/editor.ts | 9 +- src/renderer/src/store/slices/tabs.ts | 12 +- src/renderer/src/store/slices/terminals.ts | 39 +---- 6 files changed, 238 insertions(+), 49 deletions(-) create mode 100644 src/renderer/src/store/slices/degraded-repo-hydration.test.ts create mode 100644 src/renderer/src/store/slices/degraded-repo-worktree-validity.ts diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index fcc58a5fd83..9cbc4214443 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -55,6 +55,7 @@ import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' +import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity' type CreateBrowserTabOptions = { activate?: boolean @@ -1502,10 +1503,9 @@ export const createBrowserSlice: StateCreator = hydrateBrowserSession: (session, options) => { const persistedTabsByWorktree = session.browserTabsByWorktree ?? {} const currentState = get() - const validWorktreeIdsForCleanup = new Set( - Object.values(currentState.worktreesByRepo) - .flat() - .map((worktree) => worktree.id) + const validWorktreeIdsForCleanup = buildValidWorktreeIdsForSessionHydration( + currentState, + Object.keys(persistedTabsByWorktree) ) validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID) for (const workspace of currentState.folderWorkspaces) { @@ -1530,10 +1530,9 @@ export const createBrowserSlice: StateCreator = const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {} const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {} const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {} - const validWorktreeIds = new Set( - Object.values(s.worktreesByRepo) - .flat() - .map((worktree) => worktree.id) + const validWorktreeIds = buildValidWorktreeIdsForSessionHydration( + s, + Object.keys(persistedTabsByWorktree) ) validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) for (const workspace of s.folderWorkspaces) { diff --git a/src/renderer/src/store/slices/degraded-repo-hydration.test.ts b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts new file mode 100644 index 00000000000..bac0144f9e4 --- /dev/null +++ b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts @@ -0,0 +1,161 @@ +import { expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { BrowserTab, WorkspaceSessionState } from '../../../../shared/types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +// @ts-expect-error -- mocked browser preload API +globalThis.window = { api: {} } + +import { + buildWorkspaceSessionPayload, + shouldPersistWorkspaceSession +} from '@/lib/workspace-session' +import { createTestStore, makeTab } from './store-test-helpers' + +const WORKTREE_ID = 'repo1::/path/degraded' +const TERMINAL_ID = 'terminal-degraded' +const EDITOR_FILE_ID = '/path/degraded/src/App.tsx' +const BROWSER_ID = 'browser-degraded' +const GROUP_ID = 'group-degraded' + +function makeBrowserTab(): BrowserTab { + return { + id: BROWSER_ID, + worktreeId: WORKTREE_ID, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 3 + } +} + +function makeDegradedRepoSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo1', + activeWorktreeId: WORKTREE_ID, + activeTabId: TERMINAL_ID, + tabsByWorktree: { + [WORKTREE_ID]: [makeTab({ id: TERMINAL_ID, worktreeId: WORKTREE_ID })] + }, + openFilesByWorktree: { + [WORKTREE_ID]: [ + { + filePath: EDITOR_FILE_ID, + relativePath: 'src/App.tsx', + worktreeId: WORKTREE_ID, + language: 'typescript' + } + ] + }, + activeFileIdByWorktree: { [WORKTREE_ID]: EDITOR_FILE_ID }, + browserTabsByWorktree: { [WORKTREE_ID]: [makeBrowserTab()] }, + activeBrowserTabIdByWorktree: { [WORKTREE_ID]: BROWSER_ID }, + activeTabTypeByWorktree: { [WORKTREE_ID]: 'browser' }, + unifiedTabs: { + [WORKTREE_ID]: [ + { + id: TERMINAL_ID, + entityId: TERMINAL_ID, + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + { + id: EDITOR_FILE_ID, + entityId: EDITOR_FILE_ID, + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'editor', + label: 'App.tsx', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2 + }, + { + id: BROWSER_ID, + entityId: BROWSER_ID, + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'browser', + label: 'Example', + customLabel: null, + color: null, + sortOrder: 2, + createdAt: 3 + } + ] + }, + tabGroups: { + [WORKTREE_ID]: [ + { + id: GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: BROWSER_ID, + tabOrder: [TERMINAL_ID, EDITOR_FILE_ID, BROWSER_ID], + recentTabIds: [TERMINAL_ID, EDITOR_FILE_ID, BROWSER_ID] + } + ] + }, + activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID } + } +} + +function hydrateWithFailedScan( + store: ReturnType, + session: WorkspaceSessionState +): void { + store.setState({ + repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { repo1: [] }, + detectedWorktreesByRepo: { + repo1: { + repoId: 'repo1', + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + }) + store.getState().hydrateWorkspaceSession(session) + store.getState().hydrateTabsSession(session) + store.getState().hydrateEditorSession(session) + store.getState().hydrateBrowserSession(session) +} + +it('keeps tab, editor, and browser chrome through degraded hydration and persistence', () => { + const firstStore = createTestStore() + hydrateWithFailedScan(firstStore, makeDegradedRepoSession()) + firstStore.setState({ workspaceSessionReady: true }) + firstStore.getState().setHydrationSucceeded(true) + expect(shouldPersistWorkspaceSession(firstStore.getState())).toBe(true) + + const persisted = buildWorkspaceSessionPayload(firstStore.getState()) + const restoredStore = createTestStore() + hydrateWithFailedScan(restoredStore, persisted) + + const restored = restoredStore.getState() + expect(restored.unifiedTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + TERMINAL_ID, + EDITOR_FILE_ID, + BROWSER_ID + ]) + expect(restored.openFiles.map((file) => file.id)).toEqual([EDITOR_FILE_ID]) + expect(restored.browserTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([BROWSER_ID]) +}) diff --git a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts new file mode 100644 index 00000000000..a330fa44463 --- /dev/null +++ b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts @@ -0,0 +1,51 @@ +import type { DetectedWorktreeListResult, Repo, Worktree } from '../../../../shared/types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' + +type WorktreeValidityCatalog = { + repos: readonly Pick[] + worktreesByRepo: Readonly[]>> + detectedWorktreesByRepo?: Readonly< + Record | undefined> + > +} + +export function buildValidWorktreeIdsForSessionHydration( + catalog: WorktreeValidityCatalog, + persistedWorktreeIds: Iterable +): Set { + const worktreesByRepo = catalog.worktreesByRepo + const validWorktreeIds = new Set( + Object.values(worktreesByRepo) + .flat() + .map((worktree) => worktree.id) + ) + const knownRepoIds = new Set(catalog.repos.map((repo) => repo.id)) + const repoIdsWithLoadedWorktrees = new Set( + Object.entries(worktreesByRepo) + .filter(([, worktrees]) => worktrees.length > 0) + .map(([repoId]) => repoId) + ) + const repoIdsWithAuthoritativeDetectedWorktrees = new Set( + Object.entries(catalog.detectedWorktreesByRepo ?? {}) + .filter(([, detected]) => detected?.authoritative) + .map(([repoId]) => repoId) + ) + + for (const worktreeId of persistedWorktreeIds) { + if (validWorktreeIds.has(worktreeId) || parseWorkspaceKey(worktreeId)?.type === 'folder') { + continue + } + const repoId = getRepoIdFromWorktreeId(worktreeId) + // Why (#1158): a failed scan cannot prove deletion, while loaded worktrees or an authoritative scan can. + if ( + knownRepoIds.has(repoId) && + !repoIdsWithLoadedWorktrees.has(repoId) && + !repoIdsWithAuthoritativeDetectedWorktrees.has(repoId) + ) { + validWorktreeIds.add(worktreeId) + } + } + + return validWorktreeIds +} diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index df58668e752..2a810782d0d 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -69,6 +69,7 @@ import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' +import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { translate } from '@/i18n/i18n' @@ -4364,11 +4365,9 @@ export const createEditorSlice: StateCreator = (s const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {} const persistedMarkdownFrontmatterVisible = session.markdownFrontmatterVisible ?? {} - // Why: worktrees may have been deleted between sessions; drop files for worktrees that no longer exist. - const validWorktreeIds = new Set( - Object.values(s.worktreesByRepo) - .flat() - .map((w) => w.id) + const validWorktreeIds = buildValidWorktreeIdsForSessionHydration( + s, + Object.keys(openFilesByWorktree) ) validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) for (const workspace of s.folderWorkspaces) { diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index e6dc3b798d3..f591e06594b 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -36,6 +36,7 @@ import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' +import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity' export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' @@ -1966,11 +1967,12 @@ export const createTabsSlice: StateCreator = (set, hydrateTabsSession: (session, options) => { const state = get() - const validWorktreeIds = new Set( - Object.values(state.worktreesByRepo) - .flat() - .map((w) => w.id) - ) + const persistedWorktreeIds = new Set([ + ...Object.keys(session.tabsByWorktree), + ...Object.keys(session.unifiedTabs ?? {}), + ...Object.keys(session.openFilesByWorktree ?? {}) + ]) + const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(state, persistedWorktreeIds) validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) for (const workspace of state.folderWorkspaces) { validWorktreeIds.add(folderWorkspaceKey(workspace.id)) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 549d4242035..eb8623373fd 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -99,6 +99,7 @@ import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' +import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity' import { collectHibernatedCompletionEvidenceForWorktree, collectSleepingAgentSessionRecordsForWorktree, @@ -3037,45 +3038,21 @@ export const createTerminalSlice: StateCreator runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {}, worktreesByRepo: s.worktreesByRepo }) - const validWorktreeIds = new Set( - Object.values(runtimeSessionPlaceholders.worktreesByRepo) - .flat() - .map((worktree) => worktree.id) + const validWorktreeIds = buildValidWorktreeIdsForSessionHydration( + { + repos: runtimeSessionPlaceholders.repos, + worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo, + detectedWorktreesByRepo: s.detectedWorktreesByRepo + }, + Object.keys(session.tabsByWorktree) ) const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id)) - const repoIdsWithLoadedWorktrees = new Set( - Object.entries(runtimeSessionPlaceholders.worktreesByRepo) - .filter(([, worktrees]) => worktrees.length > 0) - .map(([repoId]) => repoId) - ) - const repoIdsWithAuthoritativeDetectedWorktrees = new Set( - Object.entries(s.detectedWorktreesByRepo) - .filter(([, detected]) => detected.authoritative) - .map(([repoId]) => repoId) - ) // Why: the Floating Workspace isn't a repo worktree, but its tabs use the normal session pipeline so daemon PTYs survive app restart. validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) for (const workspace of s.folderWorkspaces) { validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - for (const worktreeId of Object.keys(session.tabsByWorktree)) { - const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) - if (parsedWorkspaceKey?.type === 'folder') { - continue - } - if (!validWorktreeIds.has(worktreeId)) { - const repoId = getRepoIdFromWorktreeId(worktreeId) - // Why (#1158): an empty/missing list can mean degraded hydration; a non-empty repo list is authoritative for deleted-worktree cleanup. - if ( - knownRepoIds.has(repoId) && - !repoIdsWithLoadedWorktrees.has(repoId) && - !repoIdsWithAuthoritativeDetectedWorktrees.has(repoId) - ) { - validWorktreeIds.add(worktreeId) - } - } - } // Why pendingActivationSpawn: a restored worktree's first mount calls updateTabPtyId, which would bump lastActivityAt and bounce it to the top of Recent; the tag (consumed on the first pty update) suppresses that so only real activity bumps. const tabsByWorktree: Record = Object.fromEntries( Object.entries(session.tabsByWorktree)