diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 6afcb24a8..60a71a3eb 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -52,6 +52,7 @@ import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' +import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity' type CreateBrowserTabOptions = { activate?: boolean @@ -1582,10 +1583,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) { @@ -1610,10 +1610,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 000000000..93902bb9d --- /dev/null +++ b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts @@ -0,0 +1,211 @@ +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 makeTerminalFreeDegradedRepoSession(): WorkspaceSessionState { + const session = makeDegradedRepoSession() + session.activeTabId = null + session.tabsByWorktree = {} + session.unifiedTabs![WORKTREE_ID] = session.unifiedTabs![WORKTREE_ID].filter( + (tab) => tab.contentType !== 'terminal' + ) + session.tabGroups![WORKTREE_ID] = session.tabGroups![WORKTREE_ID].map((group) => ({ + ...group, + tabOrder: [EDITOR_FILE_ID, BROWSER_ID], + recentTabIds: [EDITOR_FILE_ID, BROWSER_ID] + })) + return session +} + +function hydrateWithRepoScan( + store: ReturnType, + session: WorkspaceSessionState, + authoritative = false +): void { + store.setState({ + repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { repo1: [] }, + detectedWorktreesByRepo: { + repo1: { + repoId: 'repo1', + authoritative, + source: authoritative ? 'git' : '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() + hydrateWithRepoScan(firstStore, makeDegradedRepoSession()) + firstStore.setState({ workspaceSessionReady: true }) + firstStore.getState().setHydrationSucceeded(true) + expect(shouldPersistWorkspaceSession(firstStore.getState())).toBe(true) + + const persisted = buildWorkspaceSessionPayload(firstStore.getState()) + const restoredStore = createTestStore() + hydrateWithRepoScan(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]) +}) + +it('keeps a terminal-free degraded workspace selected through hydration and persistence', () => { + const firstStore = createTestStore() + hydrateWithRepoScan(firstStore, makeTerminalFreeDegradedRepoSession()) + + const first = firstStore.getState() + expect(first.activeWorktreeId).toBe(WORKTREE_ID) + expect(first.activeTabType).toBe('browser') + + firstStore.setState({ workspaceSessionReady: true }) + firstStore.getState().setHydrationSucceeded(true) + const persisted = buildWorkspaceSessionPayload(firstStore.getState()) + const restoredStore = createTestStore() + hydrateWithRepoScan(restoredStore, persisted) + + const restored = restoredStore.getState() + expect(restored.activeWorktreeId).toBe(WORKTREE_ID) + expect(restored.unifiedTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + EDITOR_FILE_ID, + BROWSER_ID + ]) + expect(restored.activeTabType).toBe('browser') +}) + +it('drops terminal-free chrome when an authoritative scan proves deletion', () => { + const store = createTestStore() + hydrateWithRepoScan(store, makeTerminalFreeDegradedRepoSession(), true) + + const state = store.getState() + expect(state.activeWorktreeId).toBeNull() + expect(state.unifiedTabsByWorktree[WORKTREE_ID]).toBeUndefined() + expect(state.openFiles).toEqual([]) + expect(state.browserTabsByWorktree[WORKTREE_ID]).toBeUndefined() +}) 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 000000000..0fb4e42fd --- /dev/null +++ b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts @@ -0,0 +1,75 @@ +import type { + DetectedWorktreeListResult, + Repo, + WorkspaceSessionState, + 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 collectPersistedWorktreeIdsForSessionHydration( + session: WorkspaceSessionState +): Set { + const persistedWorktreeIds = new Set() + for (const worktreeId of Object.keys(session.tabsByWorktree)) { + persistedWorktreeIds.add(worktreeId) + } + for (const worktreeId of Object.keys(session.unifiedTabs ?? {})) { + persistedWorktreeIds.add(worktreeId) + } + for (const worktreeId of Object.keys(session.openFilesByWorktree ?? {})) { + persistedWorktreeIds.add(worktreeId) + } + for (const worktreeId of Object.keys(session.browserTabsByWorktree ?? {})) { + persistedWorktreeIds.add(worktreeId) + } + return persistedWorktreeIds +} + +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 b7ca96857..86887623f 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' @@ -4407,11 +4408,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 337f45515..fefbecd53 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -40,6 +40,10 @@ import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' +import { + buildValidWorktreeIdsForSessionHydration, + collectPersistedWorktreeIdsForSessionHydration +} from './degraded-repo-worktree-validity' export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' @@ -1971,11 +1975,8 @@ 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 = collectPersistedWorktreeIdsForSessionHydration(session) + 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 97d56c5a9..4e857129a 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -104,6 +104,10 @@ import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' +import { + buildValidWorktreeIdsForSessionHydration, + collectPersistedWorktreeIdsForSessionHydration +} from './degraded-repo-worktree-validity' import { collectHibernatedCompletionEvidenceForWorktree, collectSleepingAgentSessionRecordsForWorktree, @@ -3074,45 +3078,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 + }, + collectPersistedWorktreeIdsForSessionHydration(session) ) 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)