diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 72f93b7bd..cb881ea97 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -112,10 +112,11 @@ import { } from './lib/workspace-session' import { createSessionWriteSubscriber } from './lib/session-write-subscriber' import { - fetchWorkspaceSessionFromHosts, + fetchWorkspaceSessionWithRuntimeHostOwners, patchWorkspaceSessionByHost, persistWorkspaceSessionByHostSync } from './lib/workspace-session-host-persistence' +import { collectFolderWorkspaceKeysFromSession } from './lib/workspace-session-hydration-keys' import { getStartupErrorFallbackUI, hydratePersistedUIAfterStartupRead @@ -158,6 +159,7 @@ import { type KeybindingContext, type PhysicalModifierToken } from '../../shared/keybindings' +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { ModifierDoubleTapDetector, toModifierDoubleTapEvent @@ -180,6 +182,17 @@ const hasCustomTitleBar = shouldRenderDesktopWindowChrome({ isWebClient: isPairedWebClientWindow() }) +async function listRuntimeSessionHostIdsForStartup(): Promise { + try { + return (await window.api.runtimeEnvironments.list()).map((environment) => + toRuntimeExecutionHostId(environment.id) + ) + } catch (err) { + console.warn('Failed to list runtime session hosts for startup:', err) + return [] + } +} + function getKeybindingContext(target: EventTarget | null): KeybindingContext { return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea') ? 'terminal' @@ -855,17 +868,15 @@ function App(): React.JSX.Element { // Load settings first so a persisted remote runtime does not boot against // the local filesystem and then hydrate stale local workspace state. await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings()) - // Why: these three reads are main-side store/file reads with no - // dependency on anything fetched below, so start them now and await - // them at their original positions — the round-trips overlap the - // repo/worktree scans instead of queuing after them. Browser session - // profiles are deliberately NOT started early: on a remote runtime - // they route through a runtime RPC that may not be connected this - // early, and a failed fetch clears the profile list. The floating - // .catch marks the rejection handled if an earlier awaited step - // throws first; each await still rethrows its own failure. - const uiGetPromise = timeRendererStartupStep('ui-get', () => window.api.ui.get()) - uiGetPromise.catch(() => {}) + // Why: keybindings + onboarding are main-side reads with no dependency + // on the catalog/session steps below, so start them now and await them + // at their original positions — the round-trips overlap the local + // catalog scans instead of queuing after them. Browser session profiles + // are deliberately NOT started early: on a remote runtime they route + // through a runtime RPC that may not be connected this early, and a + // failed fetch clears the profile list. The floating .catch marks the + // rejection handled if an earlier awaited step throws first; each await + // still rethrows its own failure. const keybindingsPromise = timeRendererStartupStep('fetch-keybindings', () => actions.fetchKeybindings() ) @@ -874,36 +885,11 @@ function App(): React.JSX.Element { window.api.onboarding.get() ) onboardingPromise.catch(() => {}) - // Why: load local + every configured runtime environment (not just the - // active one) so a cold start that restored a remote workspace doesn't - // hide local repos. The sidebar "All hosts" scope then shows them all. - await timeRendererStartupStep('fetch-repos', () => actions.fetchReposForAllHosts()) - // Why: project-groups/folder-workspaces read neither the repos store nor - // worktrees, so once repos land they can overlap the per-repo - // `git worktree list` fan-out (#7225) instead of queuing ahead of it — a - // slow remote host's 15s scope RPCs no longer block the scan. folders - // still follow project-groups (they read projectGroups); all settle - // before the hydrate steps below. - const projectScopeChain = (async () => { - await timeRendererStartupStep('fetch-project-groups', () => - actions.fetchProjectGroupsForAllHosts() - ) - await timeRendererStartupStep('fetch-folder-workspaces', () => - actions.fetchFolderWorkspacesForAllHosts() - ) - })() - // Why: worktrees + lineage both fan out `git worktree list` per repo on - // the main process. Running them concurrently lets it share one - // in-flight scan per repo instead of paying the process-spawn fan-out - // twice back-to-back — the dominant renderer-chain cost on Windows - // (issue #7225). Lineage only reads settings + its own slice, so it - // does not depend on the worktrees fetch having landed. - await Promise.all([ - projectScopeChain, - timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees()), - timeRendererStartupStep('fetch-worktree-lineage', () => actions.fetchWorktreeLineage()) - ]) - const persistedUI = await uiGetPromise + // Why: hydrate persisted UI immediately after ui.get() so first paint + // reflects saved view settings before the catalog scans below. ui.get() + // is awaited (not overlapped) because the hydrate must land before the + // local-first catalog/session steps run. + const persistedUI = await timeRendererStartupStep('ui-get', () => window.api.ui.get()) uiHydrated = timeRendererStartupSyncStep('hydrate-persisted-ui', () => hydratePersistedUIAfterStartupRead({ persistedUI, @@ -911,20 +897,49 @@ function App(): React.JSX.Element { hydratePersistedUI: actions.hydratePersistedUI }) ) + const startupRuntimeHostIds = await timeRendererStartupStep( + 'list-runtime-session-hosts', + listRuntimeSessionHostIdsForStartup + ) + // Why: first paint needs local data and persisted view settings, but + // saved remote runtimes can spend the full connect timeout. Load only + // the local catalog here; remotes refresh after hydration below. + await timeRendererStartupStep('fetch-repos-local', () => + actions.fetchReposForAllHosts({ remoteHosts: 'skip' }) + ) + await timeRendererStartupStep('fetch-project-groups-local', () => + actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' }) + ) + await timeRendererStartupStep('fetch-folder-workspaces-local', () => + actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) + ) + await timeRendererStartupStep('fetch-worktrees', () => + actions.fetchAllWorktrees({ hydrationPurge: 'defer' }) + ) // Why: runtime-owned worktree slices live in per-host partitions. - // Repos were fetched above, so the known runtime hosts are derivable - // here; merge their slices into the unified session the hydrators - // expect. An unreadable host partition is skipped (fail-soft). - const session = await timeRendererStartupStep('session-get', () => - fetchWorkspaceSessionFromHosts(window.api.session, useAppStore.getState().repos) + // Remote catalogs now load after first paint, so include saved runtime + // host ids from local settings to restore their persisted session slices + // without waiting on network reachability. Unreadable partitions skip. + const sessionRead = await timeRendererStartupStep('session-get', () => + fetchWorkspaceSessionWithRuntimeHostOwners( + window.api.session, + useAppStore.getState().repos, + startupRuntimeHostIds + ) ) await keybindingsPromise if (!cancelled) { + const sessionHydrationOptions = { + additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session) + } timeRendererStartupSyncStep('hydrate-session-stores', () => { - actions.hydrateWorkspaceSession(session) - actions.hydrateTabsSession(session) - actions.hydrateEditorSession(session) - actions.hydrateBrowserSession(session) + actions.hydrateWorkspaceSession(sessionRead.session, { + ...sessionHydrationOptions, + runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey + }) + actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions) + actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) + actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions) }) // Why: prune lastVisitedAtByWorktreeId entries whose worktrees // no longer exist. Must run AFTER hydration — before this point, @@ -952,7 +967,7 @@ function App(): React.JSX.Element { // tabs through pty.attach on the relay. Passphrase-protected targets // are deferred to tab focus to avoid stacking credential dialogs at // startup before the user has context. - const connectionIds = session.activeConnectionIdsAtShutdown ?? [] + const connectionIds = sessionRead.session.activeConnectionIdsAtShutdown ?? [] if (connectionIds.length > 0) { try { const SSH_RECONNECT_TIMEOUT_MS = 15_000 @@ -1064,6 +1079,23 @@ function App(): React.JSX.Element { logRendererStartupDiagnostic('startup-hydration-done', { durationMs: Math.round(performance.now() - startupStartedAt) }) + void (async () => { + try { + await timeRendererStartupStep('remote-catalog-refresh', async () => { + await actions.fetchReposForAllHosts() + await actions.fetchProjectGroupsForAllHosts() + await actions.fetchFolderWorkspacesForAllHosts() + }) + if (!cancelled) { + await timeRendererStartupStep('remote-worktree-refresh', async () => { + await actions.fetchAllWorktrees() + await actions.fetchWorktreeLineage() + }) + } + } catch (err) { + console.warn('Remote startup catalog refresh failed:', err) + } + })() } } catch (error) { // Why (issue #1158): previously this catch called hydrateWorkspaceSession diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 81135b32c..6109a53d8 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -3,41 +3,64 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' describe('renderer startup runtime routing', () => { - it('loads settings before repo and worktree hydration', () => { + it('hydrates persisted UI before local catalog and worktree hydration', () => { const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') const startupBlockStart = source.indexOf('void (async () => {') - const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise') + const startupBlockEnd = source.indexOf("timeRendererStartupStep('session-get'") const startupBlock = source.slice(startupBlockStart, startupBlockEnd) const settingsIndex = startupBlock.indexOf('actions.fetchSettings()') - expect(settingsIndex).toBeGreaterThanOrEqual(0) - expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchReposForAllHosts()')) - expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchAllWorktrees()')) - }) - - it('overlaps sidebar scope loads with worktree hydration before session hydration', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') - const startupBlockStart = source.indexOf('void (async () => {') - const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise') - const startupBlock = source.slice(startupBlockStart, startupBlockEnd) - - const reposIndex = startupBlock.indexOf('actions.fetchReposForAllHosts()') - const scopeChainIndex = startupBlock.indexOf('const projectScopeChain = (async () => {') - const projectGroupsIndex = startupBlock.indexOf('actions.fetchProjectGroupsForAllHosts()') - const folderWorkspacesIndex = startupBlock.indexOf('actions.fetchFolderWorkspacesForAllHosts()') - const promiseAllIndex = startupBlock.indexOf('await Promise.all([') - const awaitedScopeChainIndex = startupBlock.indexOf('projectScopeChain', promiseAllIndex) - const worktreesIndex = startupBlock.indexOf('actions.fetchAllWorktrees()') + const uiGetIndex = startupBlock.indexOf("timeRendererStartupStep('ui-get'") + const hydrateUiIndex = startupBlock.indexOf( + "timeRendererStartupSyncStep('hydrate-persisted-ui'" + ) + const localReposIndex = startupBlock.indexOf( + "actions.fetchReposForAllHosts({ remoteHosts: 'skip' })" + ) + const localGroupsIndex = startupBlock.indexOf( + "actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })" + ) + const localFoldersIndex = startupBlock.indexOf( + "actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })" + ) + const localWorktreesIndex = startupBlock.indexOf( + "actions.fetchAllWorktrees({ hydrationPurge: 'defer' })" + ) const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()') - expect(reposIndex).toBeGreaterThanOrEqual(0) - expect(scopeChainIndex).toBeGreaterThan(reposIndex) - expect(projectGroupsIndex).toBeGreaterThan(scopeChainIndex) - expect(folderWorkspacesIndex).toBeGreaterThan(projectGroupsIndex) - expect(promiseAllIndex).toBeGreaterThan(scopeChainIndex) - expect(awaitedScopeChainIndex).toBeGreaterThan(promiseAllIndex) - expect(worktreesIndex).toBeGreaterThan(promiseAllIndex) - expect(lineageIndex).toBeGreaterThan(promiseAllIndex) + expect(settingsIndex).toBeGreaterThanOrEqual(0) + expect(startupBlockEnd).toBeGreaterThan(startupBlockStart) + expect(settingsIndex).toBeLessThan(uiGetIndex) + expect(uiGetIndex).toBeLessThan(hydrateUiIndex) + expect(hydrateUiIndex).toBeLessThan(localReposIndex) + expect(localReposIndex).toBeLessThan(localGroupsIndex) + expect(localGroupsIndex).toBeLessThan(localFoldersIndex) + expect(localFoldersIndex).toBeLessThan(localWorktreesIndex) + expect(lineageIndex).toBe(-1) + }) + + it('refreshes remote catalogs after startup hydration succeeds', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const hydrationDoneIndex = source.indexOf( + "logRendererStartupDiagnostic('startup-hydration-done'" + ) + const remoteCatalogIndex = source.indexOf("timeRendererStartupStep('remote-catalog-refresh'") + const remoteWorktreeIndex = source.indexOf("timeRendererStartupStep('remote-worktree-refresh'") + const lineageIndex = source.indexOf('actions.fetchWorktreeLineage()') + + expect(hydrationDoneIndex).toBeGreaterThanOrEqual(0) + expect(hydrationDoneIndex).toBeLessThan(remoteCatalogIndex) + expect(remoteCatalogIndex).toBeLessThan(remoteWorktreeIndex) + expect(remoteWorktreeIndex).toBeLessThan(lineageIndex) + expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain( + 'actions.fetchReposForAllHosts()' + ) + expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain( + 'actions.fetchProjectGroupsForAllHosts()' + ) + expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain( + 'actions.fetchFolderWorkspacesForAllHosts()' + ) }) it('waits for first-window startup services before terminal reconnect', () => { diff --git a/src/renderer/src/lib/setup-script-prompt.ts b/src/renderer/src/lib/setup-script-prompt.ts index b3cffcb5e..e60e3eaa7 100644 --- a/src/renderer/src/lib/setup-script-prompt.ts +++ b/src/renderer/src/lib/setup-script-prompt.ts @@ -112,6 +112,13 @@ export function filterSetupScriptPromptDismissalsToValidRepos( value: unknown, validRepoIds: Set ): string[] { + return sanitizeSetupScriptPromptDismissals(value).filter((entry) => { + const repoId = entry.slice(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX.length) + return validRepoIds.has(repoId) + }) +} + +export function sanitizeSetupScriptPromptDismissals(value: unknown): string[] { if (!Array.isArray(value)) { return [] } @@ -121,8 +128,7 @@ export function filterSetupScriptPromptDismissalsToValidRepos( if (typeof entry !== 'string' || !entry.startsWith(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX)) { continue } - const repoId = entry.slice(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX.length) - if (validRepoIds.has(repoId) && !next.includes(entry)) { + if (!next.includes(entry)) { next.push(entry) } } diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts new file mode 100644 index 000000000..d2bf7336a --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/types' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + buildHostIdByWorktreeId, + fetchWorkspaceSessionFromHosts, + fetchWorkspaceSessionWithRuntimeHostOwners, + patchWorkspaceSessionByHost +} from './workspace-session-host-persistence' + +describe('fetchWorkspaceSessionFromHosts', () => { + it('reads saved runtime host partitions before runtime repos are loaded', async () => { + const worktreeId = 'remote-repo::/srv/remote-wt' + const localSession: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeWorktreeId: 'local-wt' + } + const remoteSession: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [ + { + id: 'remote-tab', + ptyId: null, + worktreeId, + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + const get = vi.fn(async (hostId?: string) => + hostId === 'runtime:env-1' ? remoteSession : localSession + ) + + const session = await fetchWorkspaceSessionFromHosts({ get }, [], ['runtime:env-1']) + + expect(get).toHaveBeenCalledWith() + expect(get).toHaveBeenCalledWith('runtime:env-1') + expect(session.activeWorktreeId).toBe('local-wt') + expect(session.tabsByWorktree[worktreeId]).toEqual(remoteSession.tabsByWorktree[worktreeId]) + }) + + it('returns runtime owners for worktrees loaded from runtime host partitions', async () => { + const worktreeId = 'remote-repo::/srv/remote-wt' + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'runtime:env-1') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [ + { + id: 'remote-tab', + ptyId: null, + worktreeId, + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + } + return getDefaultWorkspaceSession() + }) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1']) + + expect(read.session.tabsByWorktree[worktreeId]).toHaveLength(1) + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' }) + }) + + it('normalizes canonical worktree session keys in runtime owner maps', async () => { + const worktreeId = 'remote-repo::/srv/remote-wt' + const workspaceKey = worktreeWorkspaceKey(worktreeId) + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'runtime:env-1') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [workspaceKey]: [ + { + id: 'remote-tab', + ptyId: null, + worktreeId, + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + } + return getDefaultWorkspaceSession() + }) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1']) + + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' }) + }) + + it('returns runtime owners for folder workspace session keys', async () => { + const folderKey = folderWorkspaceKey('folder-1') + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'runtime:env-1') { + return { + ...getDefaultWorkspaceSession(), + activeWorkspaceKey: folderKey, + tabsByWorktree: { + [folderKey]: [ + { + id: 'remote-folder-tab', + ptyId: null, + worktreeId: folderKey, + title: 'Remote folder', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + } + return getDefaultWorkspaceSession() + }) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1']) + + expect(read.session.tabsByWorktree[folderKey]).toHaveLength(1) + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [folderKey]: 'runtime:env-1' }) + }) + + it('returns runtime owners for sleeping-agent-only runtime worktrees', async () => { + const worktreeId = 'remote-repo::/srv/sleeping-wt' + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'runtime:env-1') { + return { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + 'remote-tab:leaf-1': { + paneKey: 'remote-tab:leaf-1', + tabId: 'remote-tab', + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } + } + return getDefaultWorkspaceSession() + }) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1']) + + expect(read.session.sleepingAgentSessionsByPaneKey?.['remote-tab:leaf-1']?.worktreeId).toBe( + worktreeId + ) + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' }) + }) + + it('routes restored runtime folder workspace patches back to the runtime host', async () => { + const folderKey = folderWorkspaceKey('folder-1') + const patch = vi.fn().mockResolvedValue(undefined) + + await patchWorkspaceSessionByHost( + { get: vi.fn(), patch, setSync: vi.fn() }, + { + tabsByWorktree: { + [folderKey]: [ + { + id: 'remote-folder-tab', + ptyId: null, + worktreeId: folderKey, + title: 'Remote folder', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }, + { + repos: [], + worktreesByRepo: {}, + restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' } + } + ) + + expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} })) + expect(patch).toHaveBeenCalledWith( + expect.objectContaining({ + tabsByWorktree: expect.objectContaining({ + [folderKey]: expect.any(Array) + }) + }), + 'runtime:env-1' + ) + }) + + it('keeps catalog-known local folder workspace patches local over stale restored owners', async () => { + const folderKey = folderWorkspaceKey('folder-1') + const patch = vi.fn().mockResolvedValue(undefined) + + await patchWorkspaceSessionByHost( + { get: vi.fn(), patch, setSync: vi.fn() }, + { + tabsByWorktree: { + [folderKey]: [ + { + id: 'local-folder-tab', + ptyId: null, + worktreeId: folderKey, + title: 'Local folder', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }, + { + repos: [], + folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group-1' }], + projectGroups: [{ id: 'group-1', executionHostId: 'local' }], + worktreesByRepo: {}, + restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:stale-env' } + } + ) + + expect(patch).toHaveBeenCalledTimes(1) + expect(patch.mock.calls[0][0]).toEqual( + expect.objectContaining({ + tabsByWorktree: expect.objectContaining({ + [folderKey]: expect.any(Array) + }) + }) + ) + expect(patch.mock.calls[0][1]).toBeUndefined() + }) + + it('routes placeholder-owned runtime worktree patches back to the runtime host', async () => { + const worktreeId = 'remote-repo::/srv/remote-wt' + const patch = vi.fn().mockResolvedValue(undefined) + + await patchWorkspaceSessionByHost( + { get: vi.fn(), patch, setSync: vi.fn() }, + { + tabsByWorktree: { + [worktreeId]: [ + { + id: 'remote-tab', + ptyId: null, + worktreeId, + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }, + { + repos: [{ id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' }], + worktreesByRepo: { 'remote-repo': [{ id: worktreeId, repoId: 'remote-repo' }] } + } + ) + + expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} })) + expect(patch).toHaveBeenCalledWith( + expect.objectContaining({ + tabsByWorktree: expect.objectContaining({ + [worktreeId]: expect.any(Array) + }) + }), + 'runtime:env-1' + ) + }) + + it('keeps same-id local repo worktrees in the local partition', async () => { + const localWorktreeId = 'same-repo::/Users/me/project' + const remoteWorktreeId = 'same-repo::/srv/project' + const patch = vi.fn().mockResolvedValue(undefined) + + await patchWorkspaceSessionByHost( + { get: vi.fn(), patch, setSync: vi.fn() }, + { + tabsByWorktree: { + [localWorktreeId]: [ + { + id: 'local-tab', + ptyId: null, + worktreeId: localWorktreeId, + title: 'Local', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ], + [remoteWorktreeId]: [ + { + id: 'remote-tab', + ptyId: null, + worktreeId: remoteWorktreeId, + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }, + { + repos: [ + { id: 'same-repo', connectionId: null, executionHostId: 'local' }, + { id: 'same-repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { + 'same-repo': [ + { id: localWorktreeId, repoId: 'same-repo' }, + { id: remoteWorktreeId, repoId: 'same-repo', hostId: 'runtime:env-1' } + ] + } + } + ) + + expect(patch).toHaveBeenCalledWith( + expect.objectContaining({ + tabsByWorktree: { + [localWorktreeId]: expect.any(Array) + } + }) + ) + expect(patch).toHaveBeenCalledWith( + expect.objectContaining({ + tabsByWorktree: expect.objectContaining({ + [remoteWorktreeId]: expect.any(Array) + }) + }), + 'runtime:env-1' + ) + }) + + it('defaults duplicate repo ids to local when the worktree has no host metadata', () => { + const owner = buildHostIdByWorktreeId({ + repos: [ + { id: 'same-repo', connectionId: null, executionHostId: 'local' }, + { id: 'same-repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { + 'same-repo': [{ id: 'same-repo::/local-only', repoId: 'same-repo' }] + } + }) + + expect(owner('same-repo::/local-only')).toBe('local') + }) + + it('routes canonical worktree keys using their raw worktree owner', () => { + const worktreeId = 'remote-repo::/srv/remote-wt' + const owner = buildHostIdByWorktreeId({ + repos: [], + worktreesByRepo: { + 'remote-repo': [{ id: worktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }] + } + }) + + expect(owner(worktreeWorkspaceKey(worktreeId))).toBe('runtime:env-1') + }) +}) diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 80d56c236..f59a827b7 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -10,6 +10,7 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' import { mergeWorkspaceSessionsFromHosts, @@ -20,7 +21,10 @@ import { export type HostPersistenceState = { repos: readonly Pick[] - worktreesByRepo: Record[]> + projectGroups?: readonly { id: string; executionHostId?: string | null }[] + folderWorkspaces?: readonly { id: string; projectGroupId: string }[] + worktreesByRepo: Record[]> + restoredRuntimeHostIdByWorkspaceSessionKey?: Record } type SessionApi = { @@ -29,6 +33,119 @@ type SessionApi = { setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } +export type WorkspaceSessionHostRead = { + session: WorkspaceSessionState + runtimeHostIdByWorkspaceSessionKey: Record +} + +const WORKSPACE_SESSION_KEYED_FIELDS = [ + 'tabsByWorktree', + 'openFilesByWorktree', + 'activeFileIdByWorktree', + 'activeBrowserTabIdByWorktree', + 'activeTabTypeByWorktree', + 'activeTabIdByWorktree', + 'browserTabsByWorktree', + 'unifiedTabs', + 'tabGroups', + 'tabGroupLayouts', + 'activeGroupIdByWorktree', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId' +] as const satisfies readonly (keyof WorkspaceSessionState)[] + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function normalizeWorkspaceSessionKeyForOwnerMap(value: string): string { + const scope = parseWorkspaceKey(value) + return scope?.type === 'worktree' ? scope.worktreeId : value +} + +function addWorkspaceSessionKeyForOwnerMap(ids: Set, value: unknown): void { + if (typeof value === 'string') { + ids.add(normalizeWorkspaceSessionKeyForOwnerMap(value)) + } +} + +function collectWorkspaceSessionKeysFromHostSession(session: WorkspaceSessionState): string[] { + const ids = new Set() + for (const field of WORKSPACE_SESSION_KEYED_FIELDS) { + const value = session[field] + if (isPlainRecord(value)) { + for (const id of Object.keys(value)) { + addWorkspaceSessionKeyForOwnerMap(ids, id) + } + } + } + for (const id of session.activeWorktreeIdsOnShutdown ?? []) { + addWorkspaceSessionKeyForOwnerMap(ids, id) + } + for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) { + if (!Array.isArray(pages)) { + continue + } + for (const page of pages) { + addWorkspaceSessionKeyForOwnerMap(ids, page.worktreeId) + } + } + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + // Why: a hibernated agent can be the only restored session evidence for a + // runtime worktree before its remote catalog answers. + addWorkspaceSessionKeyForOwnerMap(ids, record.worktreeId) + } + return [...ids] +} + +function buildRuntimeHostIdByWorkspaceSessionKey( + slices: HostSessionSlices +): Record { + const owners: Record = {} + for (const [hostId, slice] of nonLocalEntries(slices)) { + for (const worktreeId of collectWorkspaceSessionKeysFromHostSession(slice)) { + owners[worktreeId] = hostId + } + } + return owners +} + +function getRestoredRuntimeHostId( + owners: Record | undefined, + key: string +): ExecutionHostId | null { + const hostId = owners?.[key] + return hostId && parseExecutionHostId(hostId)?.kind === 'runtime' ? hostId : null +} + +function getFolderWorkspaceRuntimeHostId( + state: HostPersistenceState, + key: string +): ExecutionHostId { + const scope = parseWorkspaceKey(key) + if (scope?.type !== 'folder') { + return LOCAL_EXECUTION_HOST_ID + } + const workspace = state.folderWorkspaces?.find((entry) => entry.id === scope.folderWorkspaceId) + const group = workspace + ? state.projectGroups?.find((entry) => entry.id === workspace.projectGroupId) + : null + const parsed = parseExecutionHostId(group?.executionHostId) + if (parsed) { + return parsed.kind === 'runtime' ? parsed.id : LOCAL_EXECUTION_HOST_ID + } + if (workspace && group) { + // Why: once the folder and group catalogs are both known, a missing runtime + // owner is authoritative local/SSH persistence, not a startup gap. + return LOCAL_EXECUTION_HOST_ID + } + const restoredHostId = getRestoredRuntimeHostId( + state.restoredRuntimeHostIdByWorkspaceSessionKey, + key + ) + return restoredHostId ?? LOCAL_EXECUTION_HOST_ID +} + /** Map a worktree to the host partition it persists under. * * Why: only `runtime:*` worktrees are partitioned out. SSH-owned worktrees stay @@ -36,21 +153,43 @@ type SessionApi = { * the unified blob) and separately mirrors them to each target's remote * snapshot — partitioning them too would double-own that data. */ export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWorktreeId { - const repoById = new Map(state.repos.map((repo) => [repo.id, repo])) + const repoHostById = new Map() + for (const repo of state.repos) { + const hostId = getRepoExecutionHostId(repo) + const existing = repoHostById.get(repo.id) + // Why: repo ids can repeat across hosts; ambiguous repo-only ownership + // must not let a runtime placeholder steal local session state. + repoHostById.set(repo.id, existing === undefined ? hostId : existing === hostId ? hostId : null) + } const repoIdByWorktreeId = new Map() + const runtimeHostIdByWorktreeId = new Map() for (const worktrees of Object.values(state.worktreesByRepo)) { for (const worktree of worktrees) { repoIdByWorktreeId.set(worktree.id, worktree.repoId) + const parsedWorktreeHost = parseExecutionHostId(worktree.hostId) + if (parsedWorktreeHost?.kind === 'runtime') { + runtimeHostIdByWorktreeId.set(worktree.id, parsedWorktreeHost.id) + } } } return (worktreeId: string): ExecutionHostId => { - const repoId = repoIdByWorktreeId.get(worktreeId) ?? getRepoIdFromWorktreeId(worktreeId) - const repo = repoId ? repoById.get(repoId) : undefined - if (!repo) { + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + return getFolderWorkspaceRuntimeHostId(state, worktreeId) + } + const rawWorktreeId = + workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId + const worktreeHostId = runtimeHostIdByWorktreeId.get(rawWorktreeId) + if (worktreeHostId) { + return worktreeHostId + } + const repoId = repoIdByWorktreeId.get(rawWorktreeId) ?? getRepoIdFromWorktreeId(rawWorktreeId) + const repoHostId = repoId ? repoHostById.get(repoId) : undefined + if (!repoHostId) { return LOCAL_EXECUTION_HOST_ID } - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + const parsed = parseExecutionHostId(repoHostId) return parsed?.kind === 'runtime' ? parsed.id : LOCAL_EXECUTION_HOST_ID } } @@ -112,21 +251,37 @@ export function listKnownRuntimeHostIds( } /** Boot-time hydration: fetch the local partition plus one partition per known - * runtime host (repos are already loaded before session hydration in App.tsx) - * and merge them into the unified session the hydrators expect. + * runtime host (from loaded repos and saved runtime ids), then merge them into + * the unified session the hydrators expect. * * Fail-soft: a partition whose fetch rejects is skipped — boot proceeds with * the rest. Corrupt partitions never reach here; persistence zod-validates * each one and falls back to defaults on the main side. */ export async function fetchWorkspaceSessionFromHosts( api: Pick, - repos: readonly Pick[] + repos: readonly Pick[], + additionalRuntimeHostIds: readonly ExecutionHostId[] = [] ): Promise { + return (await fetchWorkspaceSessionWithRuntimeHostOwners(api, repos, additionalRuntimeHostIds)) + .session +} + +export async function fetchWorkspaceSessionWithRuntimeHostOwners( + api: Pick, + repos: readonly Pick[], + additionalRuntimeHostIds: readonly ExecutionHostId[] = [] +): Promise { const slices: HostSessionSlices = { [LOCAL_EXECUTION_HOST_ID]: await api.get() } + // Why: startup can know saved runtime session hosts before their repo + // catalogs hydrate, so include those partitions in the first read. + const runtimeHostIds = new Set([ + ...listKnownRuntimeHostIds(repos), + ...additionalRuntimeHostIds + ]) await Promise.all( - listKnownRuntimeHostIds(repos).map(async (hostId) => { + [...runtimeHostIds].map(async (hostId) => { try { slices[hostId] = await api.get(hostId) } catch (err) { @@ -134,5 +289,8 @@ export async function fetchWorkspaceSessionFromHosts( } }) ) - return mergeWorkspaceSessionsFromHosts(slices) + return { + session: mergeWorkspaceSessionsFromHosts(slices), + runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(slices) + } } diff --git a/src/renderer/src/lib/workspace-session-hydration-keys.ts b/src/renderer/src/lib/workspace-session-hydration-keys.ts new file mode 100644 index 000000000..15513b458 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-hydration-keys.ts @@ -0,0 +1,81 @@ +import type { WorkspaceKey, WorkspaceSessionState } from '../../../shared/types' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' + +export type WorkspaceSessionHydrationOptions = { + additionalValidWorkspaceKeys?: readonly WorkspaceKey[] +} + +const WORKSPACE_KEYED_SESSION_FIELDS = [ + 'tabsByWorktree', + 'openFilesByWorktree', + 'activeFileIdByWorktree', + 'activeBrowserTabIdByWorktree', + 'activeTabTypeByWorktree', + 'activeTabIdByWorktree', + 'browserTabsByWorktree', + 'unifiedTabs', + 'tabGroups', + 'tabGroupLayouts', + 'activeGroupIdByWorktree', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId' +] as const satisfies readonly (keyof WorkspaceSessionState)[] + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function addFolderWorkspaceKey(keys: Set, value: unknown): void { + if (typeof value !== 'string') { + return + } + const scope = parseWorkspaceKey(value) + if (scope?.type === 'folder') { + keys.add(value as WorkspaceKey) + } +} + +export function collectFolderWorkspaceKeysFromSession( + session: WorkspaceSessionState +): WorkspaceKey[] { + const keys = new Set() + + addFolderWorkspaceKey(keys, session.activeWorkspaceKey) + addFolderWorkspaceKey(keys, session.activeWorktreeId) + + for (const field of WORKSPACE_KEYED_SESSION_FIELDS) { + const value = session[field] + if (!isPlainRecord(value)) { + continue + } + for (const key of Object.keys(value)) { + addFolderWorkspaceKey(keys, key) + } + } + + for (const worktreeId of session.activeWorktreeIdsOnShutdown ?? []) { + addFolderWorkspaceKey(keys, worktreeId) + } + for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) { + if (!Array.isArray(pages)) { + continue + } + for (const page of pages) { + addFolderWorkspaceKey(keys, page.worktreeId) + } + } + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + addFolderWorkspaceKey(keys, record.worktreeId) + } + + return [...keys] +} + +export function addAdditionalValidWorkspaceKeys( + validWorkspaceIds: Set, + options?: WorkspaceSessionHydrationOptions +): void { + for (const key of options?.additionalValidWorkspaceKeys ?? []) { + validWorkspaceIds.add(key) + } +} diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index 1e8407bff..823705d10 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -62,11 +62,46 @@ describe('getSettingsForWorktreeRuntimeOwner', () => { expect(getExecutionHostIdForWorktree(state, 'folder:runtime-folder')).toBe('runtime:folder-env') }) + it('routes restored runtime folder workspaces before their catalog loads', () => { + const restoredFolderState: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + folderWorkspaces: [], + projectGroups: [], + restoredRuntimeHostIdByWorkspaceSessionKey: { + 'folder:restored-folder': 'runtime:restored-env' + } + } + + expect( + getSettingsForWorktreeRuntimeOwner(restoredFolderState, 'folder:restored-folder') + ).toEqual({ + activeRuntimeEnvironmentId: 'restored-env' + }) + expect(getRuntimeEnvironmentIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe( + 'restored-env' + ) + expect( + getExplicitRuntimeEnvironmentIdForWorktree(restoredFolderState, 'folder:restored-folder') + ).toBe('restored-env') + expect(getExecutionHostIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe( + 'runtime:restored-env' + ) + }) + it('keeps explicit-local folder workspaces local even while a runtime is focused', () => { expect(getSettingsForWorktreeRuntimeOwner(state, 'folder:local-folder')).toEqual({ activeRuntimeEnvironmentId: null }) expect(getExecutionHostIdForWorktree(state, 'folder:local-folder')).toBe('local') + + const restoredOwnerState: WorktreeRuntimeOwnerState = { + ...state, + restoredRuntimeHostIdByWorkspaceSessionKey: { + 'folder:local-folder': 'runtime:stale-env' + } + } + expect(getRuntimeEnvironmentIdForWorktree(restoredOwnerState, 'folder:local-folder')).toBeNull() + expect(getExecutionHostIdForWorktree(restoredOwnerState, 'folder:local-folder')).toBe('local') }) it('keeps folder workspaces with their own SSH target off the focused runtime', () => { @@ -188,6 +223,17 @@ describe('getRuntimeSessionMirrorEnvironmentIds', () => { ]) }) + it('includes restored runtime folder owners before their catalog loads', () => { + expect( + getRuntimeSessionMirrorEnvironmentIds({ + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + restoredRuntimeHostIdByWorkspaceSessionKey: { + 'folder:restored-folder': 'runtime:restored-env' + } + }) + ).toEqual(['focused-env', 'restored-env']) + }) + it('does not include local or SSH owners', () => { const localOnlyState: WorktreeRuntimeOwnerState = { settings: { activeRuntimeEnvironmentId: null }, diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index 859aeaaab..71acd81a2 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -3,7 +3,7 @@ import { parseExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' -import type { ExecutionHostId } from '../../../shared/execution-host' +import type { ExecutionHostId, ParsedExecutionHost } from '../../../shared/execution-host' import type { FolderWorkspace, GlobalSettings, @@ -11,16 +11,19 @@ import type { Repo, Worktree } from '../../../shared/types' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +type RuntimeExecutionHost = Extract + export type WorktreeRuntimeOwnerState = { repos?: readonly Pick[] settings?: Pick | null worktreesByRepo?: Record[]> folderWorkspaces?: readonly Pick[] projectGroups?: readonly Pick[] + restoredRuntimeHostIdByWorkspaceSessionKey?: Record } function findWorktreeRecord( @@ -72,9 +75,26 @@ function getRuntimeEnvironmentIdForFolderWorkspace( ) { return null } + const restoredRuntimeHost = getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId) + if (restoredRuntimeHost) { + return restoredRuntimeHost.environmentId + } return state.settings?.activeRuntimeEnvironmentId?.trim() || null } +function getRestoredRuntimeHostForFolderWorkspace( + state: WorktreeRuntimeOwnerState, + folderWorkspaceId: string +): RuntimeExecutionHost | null { + // Why: runtime folder catalogs load after session hydration; the saved + // per-host session partition is the only owner evidence during that gap. + const workspaceKey = folderWorkspaceKey(folderWorkspaceId) + const parsed = parseExecutionHostId( + state.restoredRuntimeHostIdByWorkspaceSessionKey?.[workspaceKey] + ) + return parsed?.kind === 'runtime' ? parsed : null +} + function getExplicitRuntimeEnvironmentIdFromHost( executionHostId: string | null | undefined ): string | null { @@ -101,9 +121,16 @@ function getExplicitRuntimeEnvironmentIdForFolderWorkspace( state: WorktreeRuntimeOwnerState, folderWorkspaceId: string ): string | null { - return getExplicitRuntimeEnvironmentIdFromHost( - findFolderProjectGroup(state, folderWorkspaceId)?.executionHostId - ) + const folderWorkspace = findFolderWorkspace(state, folderWorkspaceId) + const projectGroup = findFolderProjectGroup(state, folderWorkspaceId) + const parsed = parseExecutionHostId(projectGroup?.executionHostId) + if (parsed) { + return parsed.kind === 'runtime' ? parsed.environmentId : null + } + if (folderWorkspace?.connectionId?.trim() || projectGroup?.connectionId?.trim()) { + return null + } + return getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId)?.environmentId ?? null } function getExecutionHostIdForFolderWorkspace( @@ -120,6 +147,10 @@ function getExecutionHostIdForFolderWorkspace( if (connectionId) { return toSshExecutionHostId(connectionId) } + const restoredRuntimeHost = getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId) + if (restoredRuntimeHost) { + return restoredRuntimeHost.id + } const environmentId = state.settings?.activeRuntimeEnvironmentId?.trim() return environmentId ? `runtime:${encodeURIComponent(environmentId)}` : 'local' } @@ -209,6 +240,12 @@ export function getRuntimeSessionMirrorEnvironmentIds(state: WorktreeRuntimeOwne ids.add(environmentId) } } + for (const hostId of Object.values(state.restoredRuntimeHostIdByWorkspaceSessionKey ?? {})) { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'runtime') { + ids.add(parsed.environmentId) + } + } return [...ids].sort() } diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index a7f9c225b..97280cb35 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -136,6 +136,133 @@ describe('runtime RPC client routing', () => { ]) }) + it('reuses recent remote compatibility failures during startup catalog bursts', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'remote-runtime' } + }) + const target = { kind: 'environment', environmentId: 'env-offline' } as const + + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).rejects.toThrow('offline') + await expect( + callRuntimeRpc(target, 'projectGroup.list', undefined, { + reuseRecentCompatibilityFailure: true + }) + ).rejects.toThrow('offline') + await expect( + callRuntimeRpc(target, 'folderWorkspace.list', undefined, { + reuseRecentCompatibilityFailure: true + }) + ).rejects.toThrow('offline') + + expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual(['status.get']) + }) + + it('expires startup compatibility failures at the TTL boundary', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(0)) + try { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'status.get') { + statusCalls += 1 + if (statusCalls === 1) { + return Promise.resolve({ + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + const target = { kind: 'environment', environmentId: 'env-ttl' } as const + + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).rejects.toThrow('offline') + vi.setSystemTime(new Date(60_000)) + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).resolves.toEqual({ ok: true }) + + expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get', + 'status.get', + 'repo.list' + ]) + } finally { + vi.useRealTimers() + } + }) + + it('retries normal remote calls after a catalog-burst compatibility failure', async () => { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'status.get') { + statusCalls += 1 + if (statusCalls === 1) { + return Promise.resolve({ + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + const target = { kind: 'environment', environmentId: 'env-recovers' } as const + + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).rejects.toThrow('offline') + await expect(callRuntimeRpc(target, 'git.status')).resolves.toEqual({ ok: true }) + + expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get', + 'status.get', + 'git.status' + ]) + }) + it('checks advertised runtime capabilities after protocol compatibility', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'status', diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 9d9c90295..b9b1cb330 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -8,7 +8,15 @@ import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } const RUNTIME_COMPATIBILITY_CACHE_MAX = 32 -const compatibleRuntimeEnvironments = new Map>() +const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000 + +type RuntimeCompatibilityCacheEntry = { + check: Promise + failedAt: number | null + reuseFailure: boolean +} + +const runtimeCompatibilityChecks = new Map() export class RuntimeRpcCallError extends Error { readonly code: string @@ -54,10 +62,14 @@ export async function callRuntimeRpc( target: RuntimeClientTarget, method: string, params?: unknown, - options: { timeoutMs?: number; suppressFeatureInteraction?: boolean } = {} + options: { + timeoutMs?: number + suppressFeatureInteraction?: boolean + reuseRecentCompatibilityFailure?: boolean + } = {} ): Promise { if (target.kind === 'environment' && method !== 'status.get') { - await ensureRuntimeEnvironmentCompatible(target.environmentId, options.timeoutMs) + await ensureRuntimeEnvironmentCompatible(target.environmentId, options) } const nextParams = addFeatureInteractionSource(params, options) const response = @@ -84,61 +96,93 @@ function addFeatureInteractionSource( async function ensureRuntimeEnvironmentCompatible( environmentId: string, - timeoutMs?: number + options: { timeoutMs?: number; reuseRecentCompatibilityFailure?: boolean } = {} ): Promise { - const cached = compatibleRuntimeEnvironments.get(environmentId) + const cached = getCachedRuntimeCompatibilityCheck(environmentId, options) if (cached) { - compatibleRuntimeEnvironments.delete(environmentId) - compatibleRuntimeEnvironments.set(environmentId, cached) - await cached + await cached.check return } + const entry: RuntimeCompatibilityCacheEntry = { + check: Promise.resolve(), + failedAt: null, + reuseFailure: options.reuseRecentCompatibilityFailure === true + } const check = (async () => { const response = await window.api.runtimeEnvironments.call({ selector: environmentId, method: 'status.get', - timeoutMs + timeoutMs: options.timeoutMs }) const status = unwrapRuntimeRpcResult( response as RuntimeRpcResponse ) assertRuntimeStatusCompatible(status) })() - rememberRuntimeEnvironmentCompatibility(environmentId, check) + entry.check = check + rememberRuntimeEnvironmentCompatibility(environmentId, entry) try { await check } catch (error) { - if (compatibleRuntimeEnvironments.get(environmentId) === check) { - compatibleRuntimeEnvironments.delete(environmentId) + if (runtimeCompatibilityChecks.get(environmentId) === entry) { + // Why: startup asks each remote for repos, groups, then folders; an + // offline runtime should pay one timeout during that burst, not three. + entry.failedAt = Date.now() } throw error } } +function getCachedRuntimeCompatibilityCheck( + environmentId: string, + options: { reuseRecentCompatibilityFailure?: boolean } +): RuntimeCompatibilityCacheEntry | null { + const cached = runtimeCompatibilityChecks.get(environmentId) + if (!cached) { + return null + } + if ( + cached.failedAt !== null && + Date.now() - cached.failedAt >= RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS + ) { + runtimeCompatibilityChecks.delete(environmentId) + return null + } + if ( + cached.failedAt !== null && + (!cached.reuseFailure || options.reuseRecentCompatibilityFailure !== true) + ) { + return null + } + runtimeCompatibilityChecks.delete(environmentId) + runtimeCompatibilityChecks.set(environmentId, cached) + return cached +} + function rememberRuntimeEnvironmentCompatibility( environmentId: string, - check: Promise + entry: RuntimeCompatibilityCacheEntry ): void { // Why: saved/removed remote runtimes can churn through unique ids in long - // renderer sessions; successful compatibility promises should not grow forever. - compatibleRuntimeEnvironments.delete(environmentId) - compatibleRuntimeEnvironments.set(environmentId, check) - while (compatibleRuntimeEnvironments.size > RUNTIME_COMPATIBILITY_CACHE_MAX) { - const oldest = compatibleRuntimeEnvironments.keys().next().value + // renderer sessions; compatibility cache entries should not grow forever. + runtimeCompatibilityChecks.delete(environmentId) + runtimeCompatibilityChecks.set(environmentId, entry) + while (runtimeCompatibilityChecks.size > RUNTIME_COMPATIBILITY_CACHE_MAX) { + const oldest = runtimeCompatibilityChecks.keys().next().value if (oldest === undefined) { break } - compatibleRuntimeEnvironments.delete(oldest) + runtimeCompatibilityChecks.delete(oldest) } } export function clearRuntimeCompatibilityCache(environmentId?: string | null): void { const trimmed = environmentId?.trim() if (trimmed) { - compatibleRuntimeEnvironments.delete(trimmed) + runtimeCompatibilityChecks.delete(trimmed) return } - compatibleRuntimeEnvironments.clear() + runtimeCompatibilityChecks.clear() } export function markRuntimeEnvironmentCompatible(environmentId: string): void { @@ -146,7 +190,11 @@ export function markRuntimeEnvironmentCompatible(environmentId: string): void { if (!trimmed) { return } - rememberRuntimeEnvironmentCompatibility(trimmed, Promise.resolve()) + rememberRuntimeEnvironmentCompatibility(trimmed, { + check: Promise.resolve(), + failedAt: null, + reuseFailure: false + }) } export async function getRuntimeEnvironmentStatus( diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 53d07f581..96878adb3 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -49,6 +49,10 @@ import { getExecutionHostIdForWorktree, getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + addAdditionalValidWorkspaceKeys, + type WorkspaceSessionHydrationOptions +} from '@/lib/workspace-session-hydration-keys' type CreateBrowserTabOptions = { activate?: boolean @@ -168,7 +172,10 @@ export type BrowserSlice = { addBrowserPageAnnotation: (annotation: BrowserPageAnnotation) => void deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void clearBrowserPageAnnotations: (pageId: string) => void - hydrateBrowserSession: (session: WorkspaceSessionState) => void + hydrateBrowserSession: ( + session: WorkspaceSessionState, + options?: WorkspaceSessionHydrationOptions + ) => void switchBrowserTabProfile: ( workspaceId: string, profileId: string | null, @@ -1504,7 +1511,7 @@ export const createBrowserSlice: StateCreator = return { browserAnnotationsByPageId: nextByPageId } }), - hydrateBrowserSession: (session) => { + hydrateBrowserSession: (session, options) => { const persistedTabsByWorktree = session.browserTabsByWorktree ?? {} const currentState = get() const validWorktreeIdsForCleanup = new Set( @@ -1516,6 +1523,7 @@ export const createBrowserSlice: StateCreator = for (const workspace of currentState.folderWorkspaces) { validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id)) } + addAdditionalValidWorkspaceKeys(validWorktreeIdsForCleanup, options) // Why: mirror closeBrowserTab's contract — reducers are pure, imperative // side effects bracket them. Compute dropped workspaces first, destroy @@ -1548,6 +1556,7 @@ export const createBrowserSlice: StateCreator = for (const workspace of s.folderWorkspaces) { validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } + addAdditionalValidWorkspaceKeys(validWorktreeIds, options) const browserTabsByWorktree: Record = {} const browserPagesByWorkspace: Record = {} diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 805a2f801..5b451cbbc 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -62,6 +62,10 @@ import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab' import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + addAdditionalValidWorkspaceKeys, + type WorkspaceSessionHydrationOptions +} from '@/lib/workspace-session-hydration-keys' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { translate } from '@/i18n/i18n' @@ -711,7 +715,10 @@ export type EditorSlice = { setPendingEditorReveal: (reveal: PendingEditorReveal | null) => void // Session hydration — restore editor files from persisted workspace session - hydrateEditorSession: (session: WorkspaceSessionState) => void + hydrateEditorSession: ( + session: WorkspaceSessionState, + options?: WorkspaceSessionHydrationOptions + ) => void } function openWorkspaceEditorItem( @@ -4222,7 +4229,7 @@ export const createEditorSlice: StateCreator = (s // Why: only edit-mode files are restored — diffs and conflict views depend on // transient git state that may have changed between sessions. Restoring them // would show stale data or fail to load entirely. - hydrateEditorSession: (session) => { + hydrateEditorSession: (session, options) => { set((s) => { const openFilesByWorktree = session.openFilesByWorktree ?? {} const persistedActiveFileIdByWorktree = session.activeFileIdByWorktree ?? {} @@ -4241,6 +4248,7 @@ export const createEditorSlice: StateCreator = (s for (const workspace of s.folderWorkspaces) { validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } + addAdditionalValidWorkspaceKeys(validWorktreeIds, options) const openFiles: OpenFile[] = [] const editorDrafts: Record = {} diff --git a/src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts b/src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts new file mode 100644 index 000000000..a47110ddf --- /dev/null +++ b/src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 +} + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote', + badgeColor: '#000', + addedAt: 1 +} + +const localProjectGroup: ProjectGroup = { + id: 'local-group', + name: 'Local group', + parentPath: '/local', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const remoteProjectGroup: ProjectGroup = { + id: 'remote-group', + name: 'Remote group', + parentPath: '/srv', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const localFolderWorkspace: FolderWorkspace = { + id: 'local-folder', + projectGroupId: 'local-group', + name: 'Local folder', + folderPath: '/local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} + +const remoteFolderWorkspace: FolderWorkspace = { + id: 'remote-folder', + projectGroupId: 'remote-group', + name: 'Remote folder', + folderPath: '/srv', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} + +const reposList = vi.fn() +const projectsList = vi.fn() +const listHostSetups = vi.fn() +const projectGroupsList = vi.fn() +const folderWorkspacesList = vi.fn() +const runtimeEnvironmentsList = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const dispatchEventMock = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + projectsList.mockReset() + listHostSetups.mockReset() + projectGroupsList.mockReset() + folderWorkspacesList.mockReset() + runtimeEnvironmentsList.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + dispatchEventMock.mockReset() + + reposList.mockResolvedValue([localRepo]) + projectsList.mockResolvedValue([]) + listHostSetups.mockResolvedValue([]) + projectGroupsList.mockResolvedValue([localProjectGroup]) + folderWorkspacesList.mockResolvedValue([localFolderWorkspace]) + runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'lobster' }]) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectGroup.list') { + return { + id: 'rpc-project-group-list', + ok: true, + result: { groups: [remoteProjectGroup] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'folderWorkspace.list') { + return { + id: 'rpc-folder-workspace-list', + ok: true, + result: { folderWorkspaces: [remoteFolderWorkspace] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + + vi.stubGlobal('window', { + api: { + repos: { list: reposList }, + projects: { list: projectsList, listHostSetups }, + projectGroups: { list: projectGroupsList }, + folderWorkspaces: { list: folderWorkspacesList }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + list: runtimeEnvironmentsList + } + }, + dispatchEvent: dispatchEventMock + }) +}) + +describe('all-host folder workspace startup catalogs', () => { + it('loads project groups and folder workspaces for every host', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + const restoredFolderKey = folderWorkspaceKey('remote-folder') + store.setState({ + restoredRuntimeHostIdByWorkspaceSessionKey: { + [restoredFolderKey]: 'runtime:env-1', + 'remote-repo::/srv/repo': 'runtime:env-1' + } + }) + + await store.getState().fetchProjectGroupsForAllHosts() + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect(store.getState().projectGroups).toEqual([ + { ...localProjectGroup, executionHostId: 'local' }, + { ...remoteProjectGroup, executionHostId: 'runtime:env-1' } + ]) + expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([ + 'local-folder', + 'remote-folder' + ]) + expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ + 'remote-repo::/srv/repo': 'runtime:env-1' + }) + + const missingGroupStore = createTestStore() + missingGroupStore.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projectGroups: [localProjectGroup], + restoredRuntimeHostIdByWorkspaceSessionKey: { [restoredFolderKey]: 'runtime:env-1' } + }) + await missingGroupStore.getState().fetchFolderWorkspacesForAllHosts() + + expect(missingGroupStore.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ + [restoredFolderKey]: 'runtime:env-1' + }) + }) + + it('keeps local project groups and folder workspaces when a runtime is unreachable', async () => { + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'projectGroup.list' || args.method === 'folderWorkspace.list') { + throw new Error('runtime_unreachable') + } + return { + id: 'rpc-other', + ok: true, + result: { repos: [], projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchProjectGroupsForAllHosts() + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect(store.getState().projectGroups).toEqual([ + { ...localProjectGroup, executionHostId: 'local' } + ]) + expect(store.getState().folderWorkspaces).toEqual([localFolderWorkspace]) + }) + + it('does not repeat offline runtime compatibility probes across startup catalog loads', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + runtimeEnvironmentTransportCall.mockResolvedValue({ + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + const restoredFolderKey = folderWorkspaceKey('remote-folder') + store.setState({ + restoredRuntimeHostIdByWorkspaceSessionKey: { + [restoredFolderKey]: 'runtime:env-1' + } + }) + + try { + await store.getState().fetchReposForAllHosts() + await store.getState().fetchProjectGroupsForAllHosts() + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) + expect(store.getState().projectGroups).toEqual([ + { ...localProjectGroup, executionHostId: 'local' } + ]) + expect(store.getState().folderWorkspaces).toEqual([localFolderWorkspace]) + expect(runtimeEnvironmentTransportCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get' + ]) + expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ + [restoredFolderKey]: 'runtime:env-1' + }) + } finally { + warn.mockRestore() + } + }) +}) diff --git a/src/renderer/src/store/slices/repos-all-hosts.test.ts b/src/renderer/src/store/slices/repos-all-hosts.test.ts index 6471776b3..c54d0a7ed 100644 --- a/src/renderer/src/store/slices/repos-all-hosts.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts.test.ts @@ -12,6 +12,7 @@ import { type RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt' const localRepo: Repo = { id: 'local-repo', @@ -684,44 +685,109 @@ describe('fetchReposForAllHosts', () => { expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) }) - it('loads project groups and folder workspaces for every host', async () => { + it('can load only the local catalog slice for first-paint startup', async () => { const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchProjectGroupsForAllHosts() - await store.getState().fetchFolderWorkspacesForAllHosts() - - expect(store.getState().projectGroups).toEqual([ - { ...localProjectGroup, executionHostId: 'local' }, - { ...remoteProjectGroup, executionHostId: 'runtime:env-1' } - ]) - expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([ - 'local-folder', - 'remote-folder' - ]) - }) - - it('keeps local project groups and folder workspaces when a runtime is unreachable', async () => { - runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - if (args.method === 'projectGroup.list' || args.method === 'folderWorkspace.list') { - throw new Error('runtime_unreachable') - } - return { - id: 'rpc-other', - ok: true, - result: { repos: [], projects: [], setups: [] }, - _meta: { runtimeId: 'runtime-remote' } - } - }) - const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - - await store.getState().fetchProjectGroupsForAllHosts() - await store.getState().fetchFolderWorkspacesForAllHosts() + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + await store.getState().fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' }) + await store.getState().fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) + expect(runtimeEnvironmentsList).not.toHaveBeenCalled() + expect(runtimeEnvironmentTransportCall).not.toHaveBeenCalled() + expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) expect(store.getState().projectGroups).toEqual([ { ...localProjectGroup, executionHostId: 'local' } ]) expect(store.getState().folderWorkspaces).toEqual([localFolderWorkspace]) }) + + it('preserves remote repo filters during first-paint local catalog refresh', async () => { + const store = createTestStore() + const remoteDismissalKey = getSetupScriptPromptDismissalKey('remote-repo') + const staleDismissalKey = getSetupScriptPromptDismissalKey('stale-repo') + store.setState({ + activeRepoId: 'remote-repo', + filterRepoIds: ['remote-repo', 'stale-repo'], + setupScriptPromptDismissedRepoIds: [remoteDismissalKey, staleDismissalKey], + trustedOrcaHooks: { + 'remote-repo': { all: { approvedAt: 1 } }, + 'stale-repo': { all: { approvedAt: 2 } } + } + }) + + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + + expect(store.getState().activeRepoId).toBe('remote-repo') + expect(store.getState().filterRepoIds).toEqual(['remote-repo', 'stale-repo']) + expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([ + remoteDismissalKey, + staleDismissalKey + ]) + expect(store.getState().trustedOrcaHooks).toEqual({ + 'remote-repo': { all: { approvedAt: 1 } }, + 'stale-repo': { all: { approvedAt: 2 } } + }) + + await store.getState().fetchReposForAllHosts() + + expect(store.getState().activeRepoId).toBe('remote-repo') + expect(store.getState().filterRepoIds).toEqual(['remote-repo']) + expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([remoteDismissalKey]) + expect(store.getState().trustedOrcaHooks).toEqual({ + 'remote-repo': { all: { approvedAt: 1 } } + }) + }) + + it('starts remote repo catalog loads concurrently for all configured runtimes', async () => { + runtimeEnvironmentsList.mockResolvedValue([ + { id: 'env-1', name: 'first' }, + { id: 'env-2', name: 'second' } + ]) + const firstStatusResolvers = new Map void>() + let resolveBothStatusProbes = (): void => {} + const bothStatusProbesStarted = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for both runtime probes')), + 1_000 + ) + resolveBothStatusProbes = () => { + clearTimeout(timeout) + resolve() + } + }) + runtimeEnvironmentTransportCall.mockImplementation( + (args: RuntimeEnvironmentCallRequest & { selector?: string }) => { + if ( + args.method === 'status.get' && + args.selector && + !firstStatusResolvers.has(args.selector) + ) { + return new Promise((resolve) => { + firstStatusResolvers.set(args.selector!, resolve) + if (firstStatusResolvers.size === 2) { + resolveBothStatusProbes() + } + }) + } + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + } + ) + const store = createTestStore() + + const load = store.getState().fetchReposForAllHosts() + await bothStatusProbesStarted + + expect([...firstStatusResolvers.keys()].sort()).toEqual(['env-1', 'env-2']) + for (const resolve of firstStatusResolvers.values()) { + resolve(createCompatibleRuntimeStatusResponseIfNeeded({ method: 'status.get' })) + } + await load + + expect( + store + .getState() + .repos.map((repo) => `${repo.id}:${repo.executionHostId}`) + .sort() + ).toEqual(['local-repo:local', 'remote-repo:runtime:env-1', 'remote-repo:runtime:env-2']) + }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index b44f4e177..c418bb5fa 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -76,7 +76,7 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host' import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status' const ERROR_TOAST_DURATION = 60_000 @@ -126,6 +126,10 @@ export type DeleteProjectGroupWithContainedProjectsOptions = { removeContainedProjects: boolean } +type AllHostCatalogFetchOptions = { + remoteHosts?: 'include' | 'skip' +} + export type ProjectRemovalFailure = { projectId: string reason: string @@ -830,6 +834,93 @@ function mergeFetchedFolderWorkspacesForHost({ return mergeById(preserved, fetched) } +type FetchedRepoCatalog = { + repos: Repo[] + projectHostSetupCompatibility: ProjectHostSetupProjection + hostId: ReturnType +} + +type FetchedProjectGroupCatalog = { + projectGroups: ProjectGroup[] + hostId: ReturnType +} + +type FetchedFolderWorkspaceCatalog = { + folderWorkspaces: FolderWorkspace[] + hostId: ReturnType +} + +async function fetchRepoCatalogForTarget( + target: ReturnType +): Promise { + const fetchedRepos = + target.kind === 'local' + ? await window.api.repos.list() + : ( + await callRuntimeRpc<{ repos: Repo[] }>(target, 'repo.list', undefined, { + timeoutMs: 15_000, + reuseRecentCompatibilityFailure: true + }) + ).repos + const repos = fetchedRepos.map((repo) => repoWithFetchedOwner(repo, target)) + return { + repos, + projectHostSetupCompatibility: await fetchProjectHostSetupCompatibility(target, repos), + hostId: getRuntimeTargetHostId(target) + } +} + +function mergeFetchedRepoCatalog( + catalog: FetchedRepoCatalog, + currentRepos: readonly Repo[] +): { + repos: Repo[] + projectCompatibility: Pick + hostId: ReturnType +} { + const repos = mergeFetchedReposForHost(currentRepos, catalog.repos, catalog.hostId) + const projectCompatibility = mergeProjectHostSetupCompatibility( + projectCompatibilityFromRepos(repos), + catalog.projectHostSetupCompatibility + ) + return { repos, projectCompatibility, hostId: catalog.hostId } +} + +function filterTrustedOrcaHooksToValidRepos( + trust: AppState['trustedOrcaHooks'], + validRepoIds: Set +): AppState['trustedOrcaHooks'] { + const next: AppState['trustedOrcaHooks'] = {} + for (const [repoId, entry] of Object.entries(trust)) { + if (validRepoIds.has(repoId)) { + next[repoId] = entry + } + } + return next +} + +function clearRestoredFolderWorkspaceSessionOwners( + owners: AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] | undefined, + state: Pick +): AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] { + const next: AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] = {} + for (const [key, hostId] of Object.entries(owners ?? {})) { + const scope = parseWorkspaceKey(key) + if (scope?.type !== 'folder') { + next[key] = hostId + continue + } + const workspace = state.folderWorkspaces.find((entry) => entry.id === scope.folderWorkspaceId) + if (workspace && !state.projectGroups.some((group) => group.id === workspace.projectGroupId)) { + // Why: folder workspace ownership is resolved through its project group. + // If that catalog is still missing, keep the restored host owner so a + // session write before the next retry does not move runtime tabs local. + next[key] = hostId + } + } + return next +} + async function fetchReposForTarget( target: ReturnType, currentRepos: readonly Repo[] @@ -838,49 +929,87 @@ async function fetchReposForTarget( projectCompatibility: Pick hostId: ReturnType }> { - const fetchedRepos = - target.kind === 'local' - ? await window.api.repos.list() - : ( - await callRuntimeRpc<{ repos: Repo[] }>(target, 'repo.list', undefined, { - timeoutMs: 15_000 - }) - ).repos - const hostId = getRuntimeTargetHostId(target) - const repos = fetchedRepos.map((repo) => repoWithFetchedOwner(repo, target)) - const fetchedProjectCompatibility = await fetchProjectHostSetupCompatibility(target, repos) - const reconciledRepos = mergeFetchedReposForHost(currentRepos, repos, hostId) - const projectCompatibility = - target.kind === 'local' - ? mergeProjectHostSetupCompatibility( - projectCompatibilityFromRepos(reconciledRepos), - fetchedProjectCompatibility - ) - : mergeProjectHostSetupCompatibility( - projectCompatibilityFromRepos(reconciledRepos), - fetchedProjectCompatibility - ) + return mergeFetchedRepoCatalog(await fetchRepoCatalogForTarget(target), currentRepos) +} - return { repos: reconciledRepos, projectCompatibility, hostId } +async function fetchProjectGroupCatalogForTarget( + target: ReturnType +): Promise { + const fetchedGroups = + target.kind === 'local' + ? await window.api.projectGroups.list() + : ( + await callRuntimeRpc<{ groups: ProjectGroup[] }>(target, 'projectGroup.list', undefined, { + timeoutMs: 15_000, + reuseRecentCompatibilityFailure: true + }) + ).groups + return { + projectGroups: fetchedGroups.map((group) => projectGroupWithFetchedOwner(group, target)), + hostId: getRuntimeTargetHostId(target) + } +} + +function mergeFetchedProjectGroupCatalog( + catalog: FetchedProjectGroupCatalog, + currentProjectGroups: readonly ProjectGroup[] +): { projectGroups: ProjectGroup[]; hostId: ReturnType } { + return { + projectGroups: mergeFetchedProjectGroupsForHost( + currentProjectGroups, + catalog.projectGroups, + catalog.hostId + ), + hostId: catalog.hostId + } } async function fetchProjectGroupsForTarget( target: ReturnType, currentProjectGroups: readonly ProjectGroup[] ): Promise<{ projectGroups: ProjectGroup[]; hostId: ReturnType }> { - const fetchedGroups = + return mergeFetchedProjectGroupCatalog( + await fetchProjectGroupCatalogForTarget(target), + currentProjectGroups + ) +} + +async function fetchFolderWorkspaceCatalogForTarget( + target: ReturnType +): Promise { + const fetchedFolderWorkspaces = target.kind === 'local' - ? await window.api.projectGroups.list() + ? await window.api.folderWorkspaces.list() : ( - await callRuntimeRpc<{ groups: ProjectGroup[] }>(target, 'projectGroup.list', undefined, { - timeoutMs: 15_000 - }) - ).groups - const hostId = getRuntimeTargetHostId(target) - const ownedGroups = fetchedGroups.map((group) => projectGroupWithFetchedOwner(group, target)) + await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>( + target, + 'folderWorkspace.list', + undefined, + { timeoutMs: 15_000, reuseRecentCompatibilityFailure: true } + ) + ).folderWorkspaces return { - projectGroups: mergeFetchedProjectGroupsForHost(currentProjectGroups, ownedGroups, hostId), - hostId + folderWorkspaces: fetchedFolderWorkspaces, + hostId: getRuntimeTargetHostId(target) + } +} + +function mergeFetchedFolderWorkspaceCatalog( + catalog: FetchedFolderWorkspaceCatalog, + currentFolderWorkspaces: readonly FolderWorkspace[], + projectGroups: readonly ProjectGroup[] +): { + folderWorkspaces: FolderWorkspace[] + hostId: ReturnType +} { + return { + folderWorkspaces: mergeFetchedFolderWorkspacesForHost({ + previous: currentFolderWorkspaces, + fetched: catalog.folderWorkspaces, + projectGroups, + hostId: catalog.hostId + }), + hostId: catalog.hostId } } @@ -892,27 +1021,11 @@ async function fetchFolderWorkspacesForTarget( folderWorkspaces: FolderWorkspace[] hostId: ReturnType }> { - const fetchedFolderWorkspaces = - target.kind === 'local' - ? await window.api.folderWorkspaces.list() - : ( - await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>( - target, - 'folderWorkspace.list', - undefined, - { timeoutMs: 15_000 } - ) - ).folderWorkspaces - const hostId = getRuntimeTargetHostId(target) - return { - folderWorkspaces: mergeFetchedFolderWorkspacesForHost({ - previous: currentFolderWorkspaces, - fetched: fetchedFolderWorkspaces, - projectGroups, - hostId - }), - hostId - } + return mergeFetchedFolderWorkspaceCatalog( + await fetchFolderWorkspaceCatalogForTarget(target), + currentFolderWorkspaces, + projectGroups + ) } async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[]> { @@ -1155,12 +1268,12 @@ export type RepoSlice = { // Monotonic sequence so an overlapping fetchRepos can drop its own stale result (#7020). reposFetchGeneration: number fetchRepos: () => Promise - fetchReposForAllHosts: () => Promise + fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise fetchProjectGroups: () => Promise - fetchProjectGroupsForAllHosts: () => Promise + fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise fetchFolderWorkspaces: () => Promise - fetchFolderWorkspacesForAllHosts: () => Promise + fetchFolderWorkspacesForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise addRepo: () => Promise addRepoPath: ( path: string, @@ -1366,7 +1479,7 @@ export const createRepoSlice: StateCreator = (set, } }, - fetchReposForAllHosts: async () => { + fetchReposForAllHosts: async (options) => { // Why: a cold start that restores a remote workspace re-activates that // remote runtime environment, and fetching only the active host hides every // other host's repos (notably all local repos), which reads as "my projects @@ -1374,9 +1487,10 @@ export const createRepoSlice: StateCreator = (set, // sidebar "All hosts" scope shows them together regardless of which // environment is active. Each host fails soft: an unreachable/disconnected // host is skipped without blocking the others. - const applyResult = (result: Awaited>): void => { - const validRepoIds = new Set(result.repos.map((repo) => repo.id)) + const applyCatalog = (catalog: FetchedRepoCatalog): void => { + let hostRepos: Repo[] = [] set((s) => { + const result = mergeFetchedRepoCatalog(catalog, s.repos) const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ previous: { projects: s.projects, @@ -1386,48 +1500,71 @@ export const createRepoSlice: StateCreator = (set, repos: result.repos, hostId: result.hostId }) + hostRepos = result.repos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId) return { repos: result.repos, ...mergedProjectCompatibility, folderWorkspacePathStatuses: {}, - activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), - setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( - s.setupScriptPromptDismissedRepoIds, - validRepoIds - ) + activeRepoId: s.activeRepoId, + filterRepoIds: s.filterRepoIds, + setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds } }) // Why: preserve the safe-auto fork sync that fetchRepos / // fetchRuntimeEnvironmentRepos schedule after merging each host, so // cold-start (which now routes through here) keeps updating safe-auto forks. - scheduleSafeAutoForkSync( - get, - result.repos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId) - ) + scheduleSafeAutoForkSync(get, hostRepos) + } + const validateRepoScopedUi = (): void => { + set((s) => { + const validRepoIds = new Set(s.repos.map((repo) => repo.id)) + return { + activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, + filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), + setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( + s.setupScriptPromptDismissedRepoIds, + validRepoIds + ), + trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(s.trustedOrcaHooks, validRepoIds) + } + }) } // Local first so local repos are present even if a remote fetch stalls. + let failed = false try { - applyResult(await fetchReposForTarget({ kind: 'local' }, get().repos)) + applyCatalog(await fetchRepoCatalogForTarget({ kind: 'local' })) } catch (err) { + failed = true console.error('Failed to fetch local repos for all-host load:', err) } + if (options?.remoteHosts === 'skip') { + return + } const environments = await listRuntimeEnvironmentsForAllHostLoad() - - // Sequential to avoid concurrent set() races on the merged repos array. - for (const environment of environments) { - try { - applyResult( - await fetchReposForTarget( - { kind: 'environment', environmentId: environment.id }, - get().repos + // Why: unreachable remotes can spend the full connect timeout; merge each + // resolved host through the state updater so parallel loads do not clobber. + await Promise.all( + environments.map(async (environment) => { + try { + applyCatalog( + await fetchRepoCatalogForTarget({ + kind: 'environment', + environmentId: environment.id + }) ) - ) - } catch (err) { - console.warn(`Skipped repos for runtime environment ${environment.id}:`, err) - } + } catch (err) { + failed = true + console.warn(`Skipped repos for runtime environment ${environment.id}:`, err) + } + }) + ) + // Why: first-paint startup intentionally loads only local repos before + // remotes answer. Validate repo-scoped UI only once every configured host has + // answered; otherwise an offline runtime would erase its saved filters. + if (!failed) { + validateRepoScopedUi() } }, @@ -1444,35 +1581,40 @@ export const createRepoSlice: StateCreator = (set, } }, - fetchProjectGroupsForAllHosts: async () => { + fetchProjectGroupsForAllHosts: async (options) => { // Why: startup renders an all-host sidebar; replacing groups with only the // active host would leave repos from other hosts visible but ungrouped. - const applyResult = (result: Awaited>): void => { - set({ - projectGroups: result.projectGroups, + const applyCatalog = (catalog: FetchedProjectGroupCatalog): void => { + set((s) => ({ + projectGroups: mergeFetchedProjectGroupCatalog(catalog, s.projectGroups).projectGroups, folderWorkspacePathStatuses: {} - }) + })) } try { - applyResult(await fetchProjectGroupsForTarget({ kind: 'local' }, get().projectGroups)) + applyCatalog(await fetchProjectGroupCatalogForTarget({ kind: 'local' })) } catch (err) { console.error('Failed to fetch local project groups for all-host load:', err) } + if (options?.remoteHosts === 'skip') { + return + } const environments = await listRuntimeEnvironmentsForAllHostLoad() - for (const environment of environments) { - try { - applyResult( - await fetchProjectGroupsForTarget( - { kind: 'environment', environmentId: environment.id }, - get().projectGroups + await Promise.all( + environments.map(async (environment) => { + try { + applyCatalog( + await fetchProjectGroupCatalogForTarget({ + kind: 'environment', + environmentId: environment.id + }) ) - ) - } catch (err) { - console.warn(`Skipped project groups for runtime environment ${environment.id}:`, err) - } - } + } catch (err) { + console.warn(`Skipped project groups for runtime environment ${environment.id}:`, err) + } + }) + ) }, fetchFolderWorkspaces: async () => { @@ -1489,43 +1631,54 @@ export const createRepoSlice: StateCreator = (set, } }, - fetchFolderWorkspacesForAllHosts: async () => { + fetchFolderWorkspacesForAllHosts: async (options) => { // Why: folder workspaces are owned through their project groups, so startup // must fetch groups first and then merge each host's folder slice. - const applyResult = ( - result: Awaited> - ): void => { - set({ - folderWorkspaces: result.folderWorkspaces, + const applyCatalog = (catalog: FetchedFolderWorkspaceCatalog): void => { + set((s) => ({ + folderWorkspaces: mergeFetchedFolderWorkspaceCatalog( + catalog, + s.folderWorkspaces, + s.projectGroups + ).folderWorkspaces, folderWorkspacePathStatuses: {} - }) + })) } + let failed = false try { - applyResult( - await fetchFolderWorkspacesForTarget( - { kind: 'local' }, - get().folderWorkspaces, - get().projectGroups - ) - ) + applyCatalog(await fetchFolderWorkspaceCatalogForTarget({ kind: 'local' })) } catch (err) { + failed = true console.error('Failed to fetch local folder workspaces for all-host load:', err) } + if (options?.remoteHosts === 'skip') { + return + } const environments = await listRuntimeEnvironmentsForAllHostLoad() - for (const environment of environments) { - try { - applyResult( - await fetchFolderWorkspacesForTarget( - { kind: 'environment', environmentId: environment.id }, - get().folderWorkspaces, - get().projectGroups + await Promise.all( + environments.map(async (environment) => { + try { + applyCatalog( + await fetchFolderWorkspaceCatalogForTarget({ + kind: 'environment', + environmentId: environment.id + }) ) + } catch (err) { + failed = true + console.warn(`Skipped folder workspaces for runtime environment ${environment.id}:`, err) + } + }) + ) + if (!failed) { + set((s) => ({ + restoredRuntimeHostIdByWorkspaceSessionKey: clearRestoredFolderWorkspaceSessionOwners( + s.restoredRuntimeHostIdByWorkspaceSessionKey, + s ) - } catch (err) { - console.warn(`Skipped folder workspaces for runtime environment ${environment.id}:`, err) - } + })) } }, diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index d2f93e73a..7678d96f3 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -3,7 +3,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { BrowserTab, DetectedWorktreeListResult, Worktree } from '../../../../shared/types' import { isTerminalLeafId } from '../../../../shared/stable-pane-id' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + FLOATING_TERMINAL_WORKTREE_ID, + getDefaultWorkspaceSession +} from '../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -531,6 +535,143 @@ describe('hydrateWorkspaceSession', () => { }) }) +describe('restored folder workspace hydration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('keeps runtime folder workspace tabs, files, and browsers before remote catalogs load', () => { + const store = createTestStore() + const folderKey = folderWorkspaceKey('remote-folder') + const groupId = 'group-folder' + const editorFileId = '/srv/app/src/App.tsx' + const session = { + ...getDefaultWorkspaceSession(), + activeWorkspaceKey: folderKey, + activeWorktreeId: folderKey, + activeTabId: 'terminal-folder', + tabsByWorktree: { + [folderKey]: [makeTab({ id: 'terminal-folder', worktreeId: folderKey })] + }, + openFilesByWorktree: { + [folderKey]: [ + { + filePath: editorFileId, + relativePath: 'src/App.tsx', + worktreeId: folderKey, + language: 'typescript' + } + ] + }, + activeFileIdByWorktree: { [folderKey]: editorFileId }, + browserTabsByWorktree: { + [folderKey]: [ + makeBrowserTab({ + id: 'browser-folder', + worktreeId: folderKey, + url: 'https://example.com' + }) + ] + }, + browserPagesByWorkspace: { + 'browser-folder': [ + { + id: 'browser-page-folder', + workspaceId: 'browser-folder', + worktreeId: folderKey, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + activeBrowserTabIdByWorktree: { [folderKey]: 'browser-folder' }, + activeTabTypeByWorktree: { [folderKey]: 'browser' as const }, + unifiedTabs: { + [folderKey]: [ + { + id: 'terminal-folder', + entityId: 'terminal-folder', + groupId, + worktreeId: folderKey, + contentType: 'terminal' as const, + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + { + id: editorFileId, + entityId: editorFileId, + groupId, + worktreeId: folderKey, + contentType: 'editor' as const, + label: 'App.tsx', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2 + }, + { + id: 'browser-folder', + entityId: 'browser-folder', + groupId, + worktreeId: folderKey, + contentType: 'browser' as const, + label: 'Example', + customLabel: null, + color: null, + sortOrder: 2, + createdAt: 3 + } + ] + }, + tabGroups: { + [folderKey]: [ + { + id: groupId, + worktreeId: folderKey, + activeTabId: 'browser-folder', + tabOrder: ['terminal-folder', editorFileId, 'browser-folder'], + recentTabIds: ['terminal-folder', editorFileId, 'browser-folder'] + } + ] + }, + activeGroupIdByWorktree: { [folderKey]: groupId } + } + const options = { + additionalValidWorkspaceKeys: [folderKey], + runtimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' as const } + } + + store.getState().hydrateWorkspaceSession(session, options) + store.getState().hydrateTabsSession(session, options) + store.getState().hydrateEditorSession(session, options) + store.getState().hydrateBrowserSession(session, options) + + const state = store.getState() + expect(state.activeWorktreeId).toBe(folderKey) + expect(state.activeWorkspaceKey).toBe(folderKey) + expect(state.tabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual(['terminal-folder']) + expect(state.unifiedTabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual([ + 'terminal-folder', + editorFileId, + 'browser-folder' + ]) + expect(state.openFiles.map((file) => file.worktreeId)).toEqual([folderKey]) + expect(state.activeFileIdByWorktree[folderKey]).toBe(editorFileId) + expect(state.browserTabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual(['browser-folder']) + expect(state.browserPagesByWorkspace['browser-folder']?.[0]?.worktreeId).toBe(folderKey) + expect(state.activeTabTypeByWorktree[folderKey]).toBe('browser') + }) +}) + describe('hydrateBrowserSession', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 00cf7fcc1..41fa3646d 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -35,6 +35,10 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { + addAdditionalValidWorkspaceKeys, + type WorkspaceSessionHydrationOptions +} from '@/lib/workspace-session-hydration-keys' export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' @@ -172,7 +176,10 @@ export type TabsSlice = { renderableTabCount: number activeRenderableTabId: string | null } - hydrateTabsSession: (session: WorkspaceSessionState) => void + hydrateTabsSession: ( + session: WorkspaceSessionState, + options?: WorkspaceSessionHydrationOptions + ) => void } // Why: keep the TerminalTab (tabsByWorktree) pin in sync with the unified-tab @@ -2036,7 +2043,7 @@ export const createTabsSlice: StateCreator = (set, } }, - hydrateTabsSession: (session) => { + hydrateTabsSession: (session, options) => { const state = get() const validWorktreeIds = new Set( Object.values(state.worktreesByRepo) @@ -2047,6 +2054,7 @@ export const createTabsSlice: StateCreator = (set, for (const workspace of state.folderWorkspaces) { validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } + addAdditionalValidWorkspaceKeys(validWorktreeIds, options) set(buildHydratedTabState(session, validWorktreeIds)) } }) diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index aab9c2808..9b913be95 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -92,7 +92,11 @@ const mockApi = { globalThis.window = { api: mockApi } import type { WorkspaceSessionState } from '../../../../shared/types' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + FLOATING_TERMINAL_WORKTREE_ID, + getDefaultWorkspaceSession +} from '../../../../shared/constants' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { createTestStore, makeLayout, makeTab, makeWorktree, seedStore } from './store-test-helpers' import { canGoBackWorktreeHistory } from './worktree-nav-history' @@ -138,6 +142,145 @@ describe('hydrateWorkspaceSession', () => { }) }) + it('hydrates runtime-owned tabs from host partitions before remote catalogs load', () => { + const store = createTestStore() + const worktreeId = 'remote-repo::/srv/remote-wt' + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'remote-repo', + activeWorktreeId: worktreeId, + activeTabId: 'remote-tab', + activeWorktreeIdsOnShutdown: [worktreeId], + tabsByWorktree: { + [worktreeId]: [ + makeTab({ + id: 'remote-tab', + worktreeId, + ptyId: 'remote-session' + }) + ] + }, + remoteSessionIdsByTabId: { 'remote-tab': 'remote-session' } + } + + store.getState().hydrateWorkspaceSession(session, { + runtimeHostIdByWorkspaceSessionKey: { [worktreeId]: 'runtime:env-1' } + }) + + expect(store.getState().tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'remote-tab' + ]) + expect(store.getState().activeWorktreeId).toBe(worktreeId) + expect(store.getState().activeRepoId).toBe('remote-repo') + expect(store.getState().pendingReconnectWorktreeIds).toEqual([worktreeId]) + expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({ + 'remote-tab': 'remote-session' + }) + expect(store.getState().repos).toEqual([ + expect.objectContaining({ + id: 'remote-repo', + executionHostId: 'runtime:env-1' + }) + ]) + expect(store.getState().worktreesByRepo['remote-repo']).toEqual([ + expect.objectContaining({ + id: worktreeId, + hostId: 'runtime:env-1' + }) + ]) + }) + + it('avoids duplicate repo placeholders when a same-id local repo is already loaded', () => { + const store = createTestStore() + const worktreeId = 'same-repo::/srv/remote-wt' + store.setState({ + repos: [ + { + id: 'same-repo', + path: '/Users/me/same-repo', + displayName: 'Same repo', + badgeColor: '#000', + addedAt: 1, + connectionId: null, + executionHostId: 'local' + } + ], + worktreesByRepo: {} + }) + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'same-repo', + activeWorktreeId: worktreeId, + activeTabId: 'remote-tab', + activeWorktreeIdsOnShutdown: [worktreeId], + tabsByWorktree: { + [worktreeId]: [ + makeTab({ + id: 'remote-tab', + worktreeId, + ptyId: 'remote-session' + }) + ] + } + } + + store.getState().hydrateWorkspaceSession(session, { + runtimeHostIdByWorkspaceSessionKey: { + [worktreeWorkspaceKey(worktreeId)]: 'runtime:env-1' + } + }) + + expect(store.getState().repos.map((repo) => `${repo.id}:${repo.executionHostId}`)).toEqual([ + 'same-repo:local' + ]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([ + expect.objectContaining({ id: worktreeId, hostId: 'runtime:env-1' }) + ]) + expect(store.getState().tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'remote-tab' + ]) + }) + + it('hydrates runtime folder workspace tabs before remote folder catalogs load', () => { + const store = createTestStore() + const folderKey = folderWorkspaceKey('folder-1') + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeWorkspaceKey: folderKey, + activeWorktreeId: folderKey, + activeTabId: 'remote-folder-tab', + activeWorktreeIdsOnShutdown: [folderKey], + tabsByWorktree: { + [folderKey]: [ + makeTab({ + id: 'remote-folder-tab', + worktreeId: folderKey, + ptyId: 'remote-folder-session' + }) + ] + }, + remoteSessionIdsByTabId: { 'remote-folder-tab': 'remote-folder-session' } + } + + store.getState().hydrateWorkspaceSession(session, { + additionalValidWorkspaceKeys: [folderKey], + runtimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' } + }) + + expect(store.getState().tabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual([ + 'remote-folder-tab' + ]) + expect(store.getState().activeWorktreeId).toBe(folderKey) + expect(store.getState().activeWorkspaceKey).toBe(folderKey) + expect(store.getState().pendingReconnectWorktreeIds).toEqual([folderKey]) + expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({ + 'remote-folder-tab': 'remote-folder-session' + }) + expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ + [folderKey]: 'runtime:env-1' + }) + }) + it('moves restored active focus from a dead split leaf to a pty-backed sibling', () => { const store = createTestStore() const worktreeId = 'repo1::/wt-1' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 3b99e3837..260e642c7 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { + Repo, SetupSplitDirection, Tab, TerminalLayoutSnapshot, @@ -15,7 +16,11 @@ import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + DEFAULT_REPO_BADGE_COLOR, + FLOATING_TERMINAL_WORKTREE_ID +} from '../../../../shared/constants' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { folderWorkspaceKey, parseWorkspaceKey, @@ -65,6 +70,10 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' +import { + addAdditionalValidWorkspaceKeys, + type WorkspaceSessionHydrationOptions +} from '@/lib/workspace-session-hydration-keys' import { collectHibernatedCompletionEvidenceForWorktree, collectSleepingAgentSessionRecordsForWorktree, @@ -120,6 +129,89 @@ function getFallbackTabTitle(tab: TerminalTab, index?: number): string { ) } +function getPathDisplayName(path: string, fallback: string): string { + const normalized = path.trim().replace(/[\\/]+$/g, '') + const basename = normalized.split(/[\\/]/).findLast(Boolean)?.trim() + return basename || fallback +} + +function buildRuntimeSessionPlaceholders({ + repos, + runtimeHostIdByWorkspaceSessionKey, + worktreesByRepo +}: { + repos: readonly Repo[] + runtimeHostIdByWorkspaceSessionKey: Record + worktreesByRepo: Record +}): { repos: Repo[]; worktreesByRepo: Record } { + let nextRepos = repos.slice() + let nextWorktreesByRepo = worktreesByRepo + for (const workspaceSessionKey of Object.keys(runtimeHostIdByWorkspaceSessionKey)) { + const hostId = runtimeHostIdByWorkspaceSessionKey[workspaceSessionKey] + if (parseExecutionHostId(hostId)?.kind !== 'runtime') { + continue + } + const workspaceScope = parseWorkspaceKey(workspaceSessionKey) + if (workspaceScope?.type === 'folder') { + continue + } + const worktreeId = + workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : workspaceSessionKey + const parsed = splitWorktreeId(worktreeId) + if (!parsed) { + continue + } + const existingRepo = nextRepos.some((repo) => repo.id === parsed.repoId) + if (!existingRepo) { + // Why: remote catalogs load after hydration, but host-split session + // writes need owner metadata. If any repo with this id already exists, + // avoid duplicate ids; the worktree placeholder below carries hostId. + nextRepos = [ + ...nextRepos, + { + id: parsed.repoId, + path: parsed.worktreePath, + displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId), + badgeColor: DEFAULT_REPO_BADGE_COLOR, + addedAt: 0, + connectionId: null, + executionHostId: hostId + } + ] + } + const current = nextWorktreesByRepo[parsed.repoId] ?? [] + if (current.some((worktree) => worktree.id === worktreeId)) { + continue + } + const placeholder: Worktree = { + id: worktreeId, + repoId: parsed.repoId, + hostId, + displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId), + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + path: parsed.worktreePath, + head: '', + branch: '', + isBare: false, + isMainWorktree: false + } + nextWorktreesByRepo = + nextWorktreesByRepo === worktreesByRepo ? { ...worktreesByRepo } : nextWorktreesByRepo + nextWorktreesByRepo[parsed.repoId] = [...current, placeholder] + } + return { repos: nextRepos, worktreesByRepo: nextWorktreesByRepo } +} + let terminalTabOwnerCacheSource: Record | null = null let terminalTabOwnerCache = new Map() @@ -415,6 +507,7 @@ export type TerminalSlice = { pendingIssueCommandSplitByTabId: Record }> tabBarOrderByWorktree: Record workspaceSessionReady: boolean + restoredRuntimeHostIdByWorkspaceSessionKey: Record defaultTerminalTabsAppliedByWorktreeId: Record markDefaultTerminalTabsApplied: (worktreeId: string) => void /** True only after hydrateWorkspaceSession ran from a real load of @@ -618,10 +711,17 @@ export type TerminalSlice = { setDeferredSshReconnectTargets: (targetIds: string[]) => void removeDeferredSshReconnectTarget: (targetId: string) => void removeDeferredSshSessionId: (tabId: string) => void - hydrateWorkspaceSession: (session: WorkspaceSessionState) => void + hydrateWorkspaceSession: ( + session: WorkspaceSessionState, + options?: HydrateWorkspaceSessionOptions + ) => void reconnectPersistedTerminals: (signal?: AbortSignal) => Promise } +export type HydrateWorkspaceSessionOptions = { + runtimeHostIdByWorkspaceSessionKey?: Record +} & WorkspaceSessionHydrationOptions + export const createTerminalSlice: StateCreator = (set, get) => ({ tabsByWorktree: {}, activeTabId: null, @@ -645,6 +745,7 @@ export const createTerminalSlice: StateCreator nativeChatLaunchPromptByTabId: {}, tabBarOrderByWorktree: {}, workspaceSessionReady: false, + restoredRuntimeHostIdByWorkspaceSessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, markDefaultTerminalTabsApplied: (worktreeId) => set((s) => { @@ -2741,16 +2842,21 @@ export const createTerminalSlice: StateCreator return data }, - hydrateWorkspaceSession: (session) => { + hydrateWorkspaceSession: (session, options) => { set((s) => { + const runtimeSessionPlaceholders = buildRuntimeSessionPlaceholders({ + repos: s.repos, + runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {}, + worktreesByRepo: s.worktreesByRepo + }) const validWorktreeIds = new Set( - Object.values(s.worktreesByRepo) + Object.values(runtimeSessionPlaceholders.worktreesByRepo) .flat() .map((worktree) => worktree.id) ) - const knownRepoIds = new Set(s.repos.map((r) => r.id)) + const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id)) const repoIdsWithLoadedWorktrees = new Set( - Object.entries(s.worktreesByRepo) + Object.entries(runtimeSessionPlaceholders.worktreesByRepo) .filter(([, worktrees]) => worktrees.length > 0) .map(([repoId]) => repoId) ) @@ -2766,6 +2872,7 @@ export const createTerminalSlice: StateCreator 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') { @@ -2841,9 +2948,10 @@ export const createTerminalSlice: StateCreator ) const fallbackActiveWorktreeId = !session.activeWorktreeId && session.activeRepoId && knownRepoIds.has(session.activeRepoId) - ? (s.worktreesByRepo[session.activeRepoId]?.find((worktree) => worktree.isMainWorktree) - ?.id ?? - s.worktreesByRepo[session.activeRepoId]?.[0]?.id ?? + ? (runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.find( + (worktree) => worktree.isMainWorktree + )?.id ?? + runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.[0]?.id ?? null) : null const activeWorktreeId = (() => { @@ -2866,7 +2974,8 @@ export const createTerminalSlice: StateCreator const activeTabId = session.activeTabId && validTabIds.has(session.activeTabId) ? session.activeTabId : null const activeRepoId = - session.activeRepoId && s.repos.some((repo) => repo.id === session.activeRepoId) + session.activeRepoId && + runtimeSessionPlaceholders.repos.some((repo) => repo.id === session.activeRepoId) ? session.activeRepoId : null @@ -2908,10 +3017,12 @@ export const createTerminalSlice: StateCreator // createOrAttach RPC, triggering reattach instead of a fresh spawn. const pendingReconnectPtyIdByTabId: Record = {} for (const worktreeId of pendingReconnectWorktreeIds) { - const worktree = Object.values(s.worktreesByRepo) + const worktree = Object.values(runtimeSessionPlaceholders.worktreesByRepo) .flat() .find((entry) => entry.id === worktreeId) - const repo = worktree ? s.repos.find((entry) => entry.id === worktree.repoId) : null + const repo = worktree + ? runtimeSessionPlaceholders.repos.find((entry) => entry.id === worktree.repoId) + : null if (repo?.connectionId) { continue } @@ -2963,8 +3074,10 @@ export const createTerminalSlice: StateCreator // data once SSH reconnects and fetchWorktrees runs. // Why: only SSH needs placeholders; local metadata should come from the // next successful fetch so the sidebar does not render synthetic entries. - const sshRepoIds = new Set(s.repos.filter((r) => r.connectionId).map((r) => r.id)) - const worktreesByRepo = { ...s.worktreesByRepo } + const sshRepoIds = new Set( + runtimeSessionPlaceholders.repos.filter((r) => r.connectionId).map((r) => r.id) + ) + const worktreesByRepo = { ...runtimeSessionPlaceholders.worktreesByRepo } for (const worktreeId of Object.keys(tabsByWorktree)) { const repoId = getRepoIdFromWorktreeId(worktreeId) if (!sshRepoIds.has(repoId)) { @@ -3017,6 +3130,9 @@ export const createTerminalSlice: StateCreator activeWorkspaceKey, activeTabId, activeTabIdByWorktree, + restoredRuntimeHostIdByWorkspaceSessionKey: + options?.runtimeHostIdByWorkspaceSessionKey ?? {}, + repos: runtimeSessionPlaceholders.repos, tabsByWorktree, worktreesByRepo, // Why: restore the per-worktree focus-recency map. Pruning of stale diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 64899d1a1..6b4e88cbc 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -21,6 +21,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' +import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt' const mocks = vi.hoisted(() => ({ sendNotesToActiveAgentSession: vi.fn(), @@ -763,6 +764,56 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().rightSidebarExplorerView).toBe('files') }) + it('preserves persisted repo filters until repos are loaded', () => { + const store = createUIStore() + const remoteDismissalKey = getSetupScriptPromptDismissalKey('remote-repo') + + store.getState().hydratePersistedUI( + makePersistedUI({ + filterRepoIds: ['remote-repo', 12 as never, 'stale-repo'], + trustedOrcaHooks: { + 'remote-repo': { all: { approvedAt: 1 } }, + 'bad-shape': 'yes' as never + }, + setupScriptPromptDismissedRepoIds: [remoteDismissalKey, 'remote-repo', remoteDismissalKey] + }) + ) + + expect(store.getState().filterRepoIds).toEqual(['remote-repo', 'stale-repo']) + expect(store.getState().trustedOrcaHooks).toEqual({ + 'remote-repo': { all: { approvedAt: 1 } } + }) + expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([remoteDismissalKey]) + }) + + it('validates persisted repo filters when repos are already loaded', () => { + const store = createUIStore() + const localDismissalKey = getSetupScriptPromptDismissalKey('local-repo') + const staleDismissalKey = getSetupScriptPromptDismissalKey('stale-repo') + store.setState({ + repos: [ + { id: 'local-repo', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 1 } + ] + } as Partial) + + store.getState().hydratePersistedUI( + makePersistedUI({ + filterRepoIds: ['local-repo', 'stale-repo'], + trustedOrcaHooks: { + 'local-repo': { all: { approvedAt: 1 } }, + 'stale-repo': { all: { approvedAt: 2 } } + }, + setupScriptPromptDismissedRepoIds: [localDismissalKey, staleDismissalKey] + }) + ) + + expect(store.getState().filterRepoIds).toEqual(['local-repo']) + expect(store.getState().trustedOrcaHooks).toEqual({ + 'local-repo': { all: { approvedAt: 1 } } + }) + expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([localDismissalKey]) + }) + it('hydrates legacy persisted search tab as Explorer search', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index c8e5f112e..3a0205cbe 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -89,7 +89,8 @@ import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' import type { SettingsNavTarget } from '@/lib/settings-navigation-types' import { filterSetupScriptPromptDismissalsToValidRepos, - getSetupScriptPromptDismissalKey + getSetupScriptPromptDismissalKey, + sanitizeSetupScriptPromptDismissals } from '../../lib/setup-script-prompt' import { DEFAULT_PET_ID, isBundledPetId } from '../../components/pet/pet-models' import { revokeCustomPetBlobUrl } from '../../components/pet/pet-blob-cache' @@ -349,12 +350,38 @@ function collectAcknowledgedAgentNotificationId({ } } -function filterTrustedOrcaHooksToValidRepos( - trust: PersistedTrustedOrcaHooks, - validRepoIds: Set -): PersistedTrustedOrcaHooks { +function isPlainPersistedRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function sanitizePersistedRepoIds(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((repoId): repoId is string => typeof repoId === 'string') +} + +function sanitizeTrustedOrcaHooks(trust: unknown): PersistedTrustedOrcaHooks { + if (!isPlainPersistedRecord(trust)) { + return {} + } const next: PersistedTrustedOrcaHooks = {} for (const [repoId, entry] of Object.entries(trust)) { + if (!isSafePersistedRecordKey(repoId) || !isPlainPersistedRecord(entry)) { + continue + } + next[repoId] = entry as PersistedTrustedOrcaHooks[string] + } + return next +} + +function filterTrustedOrcaHooksToValidRepos( + trust: unknown, + validRepoIds: Set +): PersistedTrustedOrcaHooks { + const sanitized = sanitizeTrustedOrcaHooks(trust) + const next: PersistedTrustedOrcaHooks = {} + for (const [repoId, entry] of Object.entries(sanitized)) { if (validRepoIds.has(repoId)) { next[repoId] = entry } @@ -362,6 +389,17 @@ function filterTrustedOrcaHooksToValidRepos( return next } +function hydrateTrustedOrcaHooks( + trust: unknown, + validRepoIds: Set +): PersistedTrustedOrcaHooks { + const sanitized = sanitizeTrustedOrcaHooks(trust) + if (validRepoIds.size === 0) { + return sanitized + } + return filterTrustedOrcaHooksToValidRepos(sanitized, validRepoIds) +} + function isSafePersistedRecordKey(key: string): boolean { return key !== '__proto__' && key !== 'constructor' && key !== 'prototype' } @@ -2192,6 +2230,7 @@ export const createUISlice: StateCreator = (set, get) hydratePersistedUI: (ui) => set((s) => { const validRepoIds = new Set(s.repos.map((repo) => repo.id)) + const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds) // Why: persisted UI from pre-rename builds used sidekick* keys. Read // those only as fallbacks so new pet* writes win immediately after upgrade. const customPets = Array.isArray(ui.customPets) @@ -2283,7 +2322,12 @@ export const createUISlice: StateCreator = (set, get) hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), - filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)), + // Why: startup hydrates UI before repo catalogs now. With no catalog + // loaded yet, defer repo-filter validation to the all-host repo refresh. + filterRepoIds: + validRepoIds.size === 0 + ? persistedFilterRepoIds + : persistedFilterRepoIds.filter((repoId) => validRepoIds.has(repoId)), collapsedGroups: new Set(ui.collapsedGroups ?? []), uiZoomLevel: ui.uiZoomLevel ?? 0, editorFontZoomLevel: ui.editorFontZoomLevel ?? 0, @@ -2332,14 +2376,14 @@ export const createUISlice: StateCreator = (set, get) typeof ui.contextualToursAutoEligible === 'boolean' ? ui.contextualToursAutoEligible : null, - trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos( - ui.trustedOrcaHooks ?? {}, - validRepoIds - ), - setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( - ui.setupScriptPromptDismissedRepoIds, - validRepoIds - ), + trustedOrcaHooks: hydrateTrustedOrcaHooks(ui.trustedOrcaHooks, validRepoIds), + setupScriptPromptDismissedRepoIds: + validRepoIds.size === 0 + ? sanitizeSetupScriptPromptDismissals(ui.setupScriptPromptDismissedRepoIds) + : filterSetupScriptPromptDismissalsToValidRepos( + ui.setupScriptPromptDismissedRepoIds, + validRepoIds + ), setupGuideSidebarDismissed: ui.setupGuideSidebarDismissed === true, setupGuideBrowserMilestoneMigrated: ui.setupGuideBrowserMilestoneMigrated === true, setupGuideBrowserMilestoneLegacyComplete: diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index fbeee1fb7..bbc5575ae 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -112,7 +112,7 @@ export type WorktreeSlice = { hasHydratedWorktreePurge: boolean fetchDetectedWorktrees: (repoId: string) => Promise fetchWorktrees: (repoId: string, options?: { requireAuthoritative?: boolean }) => Promise - fetchAllWorktrees: () => Promise + fetchAllWorktrees: (options?: { hydrationPurge?: 'allow' | 'defer' }) => Promise fetchWorktreeLineage: () => Promise updateWorktreeLineage: ( worktreeId: string, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index cdaf8a101..b9b5b0bdb 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -5493,6 +5493,8 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { const store = createTestStore() const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' }) + const folderWorkspace = makeFolderWorkspace({ id: 'folder-keep' }) + const folderKey = folderWorkspaceKey(folderWorkspace.id) mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => repoId === 'repoA' ? [wtA] : [wtB] @@ -5500,15 +5502,18 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { store.setState({ repos: [repoA, repoB], + folderWorkspaces: [folderWorkspace], tabsByWorktree: { 'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }], 'repoA::/a/zombie': [{ id: 'tab-zombie', worktreeId: 'repoA::/a/zombie' }], - 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] + 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }], + [folderKey]: [{ id: 'tab-folder', worktreeId: folderKey }] }, gitIgnoredPathsByWorktree: { 'repoA::/a/wt1': ['dist/'], 'repoA::/a/zombie': ['coverage/'], - 'repoB::/b/wt1': ['build/'] + 'repoB::/b/wt1': ['build/'], + [folderKey]: ['tmp/'] } } as unknown as Partial) @@ -5518,11 +5523,13 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(mockApi.worktrees.list).toHaveBeenCalledTimes(2) expect(store.getState().tabsByWorktree).toEqual({ 'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }], - 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] + 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }], + [folderKey]: [{ id: 'tab-folder', worktreeId: folderKey }] }) expect(store.getState().gitIgnoredPathsByWorktree).toEqual({ 'repoA::/a/wt1': ['dist/'], - 'repoB::/b/wt1': ['build/'] + 'repoB::/b/wt1': ['build/'], + [folderKey]: ['tmp/'] }) // Second call must not re-run the purge even if new stale ids appear. @@ -5539,6 +5546,79 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(store.getState().tabsByWorktree['repoA::/a/new-zombie']).toBeDefined() }) + it('can defer the first successful purge during local-only startup refresh', async () => { + const store = createTestStore() + const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) + const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' }) + + mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => + repoId === 'repoA' ? [wtA] : [wtB] + ) + + store.setState({ + repos: [repoA, repoB], + tabsByWorktree: { + 'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }], + 'repoA::/a/zombie': [{ id: 'tab-zombie', worktreeId: 'repoA::/a/zombie' }], + 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] + } + } as unknown as Partial) + + await store.getState().fetchAllWorktrees({ hydrationPurge: 'defer' }) + + expect(store.getState().hasHydratedWorktreePurge).toBe(false) + expect(store.getState().tabsByWorktree['repoA::/a/zombie']).toBeDefined() + + await store.getState().fetchAllWorktrees() + + expect(store.getState().hasHydratedWorktreePurge).toBe(true) + expect(store.getState().tabsByWorktree).toEqual({ + 'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }], + 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] + }) + }) + + it('does not consume the one-shot purge before clean workspace session hydration', async () => { + const store = createTestStore() + const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) + const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' }) + + mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => + repoId === 'repoA' ? [wtA] : [wtB] + ) + + store.setState({ + workspaceSessionReady: false, + hydrationSucceeded: false, + repos: [repoA, repoB], + tabsByWorktree: { + 'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }], + 'repoA::/a/zombie': [{ id: 'tab-zombie', worktreeId: 'repoA::/a/zombie' }], + 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] + } + } as unknown as Partial) + + await store.getState().fetchAllWorktrees() + + expect(store.getState().hasHydratedWorktreePurge).toBe(false) + expect(store.getState().tabsByWorktree['repoA::/a/zombie']).toBeDefined() + + store.setState({ workspaceSessionReady: true } as Partial) + await store.getState().fetchAllWorktrees() + + expect(store.getState().hasHydratedWorktreePurge).toBe(false) + expect(store.getState().tabsByWorktree['repoA::/a/zombie']).toBeDefined() + + store.setState({ hydrationSucceeded: true } as Partial) + await store.getState().fetchAllWorktrees() + + expect(store.getState().hasHydratedWorktreePurge).toBe(true) + expect(store.getState().tabsByWorktree).toEqual({ + 'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }], + 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] + }) + }) + // Why: multi-host regression — once hydration has fired, a mid-session // fetchAllWorktrees (e.g. triggered by switching focus) must NEVER purge // terminal state, even if a host transiently reports zero worktrees. The diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index a3236500d..248bde1eb 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -2295,7 +2295,7 @@ export const createWorktreeSlice: StateCreator } }, - fetchAllWorktrees: async () => { + fetchAllWorktrees: async (options) => { const { repos } = get() // Why: once the one-shot hydration-time purge has fired, subsequent @@ -2437,10 +2437,31 @@ export const createWorktreeSlice: StateCreator // Defer; try again on the next fetchAllWorktrees call. return } + if ( + options?.hydrationPurge === 'defer' || + get().workspaceSessionReady === false || + get().hydrationSucceeded === false + ) { + // Why: startup first refreshes only local repos so the app can paint + // before remote runtime timeouts. Keep the one-shot purge available for + // the later all-host refresh, after a clean session hydrate and when + // remote worktree ids are known too. + return + } const validIds = new Set() // Why: floating is persisted renderer state, but not a repo worktree that // authoritative runtime scans can return. validIds.add(FLOATING_TERMINAL_WORKTREE_ID) + // Why: folder workspaces persist terminal tabs under `folder:` keys, + // but authoritative repo scans can never return those synthetic ids. + for (const workspace of get().folderWorkspaces ?? []) { + validIds.add(folderWorkspaceKey(workspace.id)) + } + for (const key of Object.keys(get().restoredRuntimeHostIdByWorkspaceSessionKey ?? {})) { + if (parseWorkspaceKey(key)?.type === 'folder') { + validIds.add(key) + } + } for (const result of Object.values(get().detectedWorktreesByRepo)) { if (!result.authoritative) { continue