From 487dcabff50994c0f3029031e8fb54a28bce5545 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 17:54:23 +0300 Subject: [PATCH] fix: harden recent project and sidebar selection state --- .../ports/RecentProjectsCachePort.ts | 1 + .../ListDashboardRecentProjectsUseCase.ts | 12 ++++ .../cache/InMemoryRecentProjectsCache.ts | 5 +- .../sidebar/dateGroupedSessionsSelection.ts | 26 ++++---- ...ListDashboardRecentProjectsUseCase.test.ts | 60 +++++++++++++++++++ .../dateGroupedSessionsSelection.test.ts | 38 ++++++++++++ 6 files changed, 131 insertions(+), 11 deletions(-) diff --git a/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts b/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts index 2a76dd00..92070e30 100644 --- a/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts +++ b/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts @@ -1,4 +1,5 @@ export interface RecentProjectsCachePort { get(key: string): Promise; + getStale(key: string): Promise; set(key: string, value: T, ttlMs: number): Promise; } diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts index 7abf05db..5745aacd 100644 --- a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -57,6 +57,7 @@ export class ListDashboardRecentProjectsUseCase { async #executeUncached(cacheKey: string): Promise { const startedAt = this.deps.clock.now(); + const stale = await this.deps.cache.getStale(cacheKey); const results = await Promise.all( this.deps.sources.map((source, index) => this.#loadSource(source, index)) ); @@ -64,6 +65,17 @@ export class ListDashboardRecentProjectsUseCase { const successful = results.flatMap((result) => result.candidates); const hasDegradedSources = results.some((result) => result.degraded); + if (hasDegradedSources && stale) { + await this.deps.cache.set(cacheKey, stale, this.#degradedCacheTtlMs); + this.deps.logger.info('recent-projects served stale cache', { + cacheKey, + degradedSources: results.filter((result) => result.degraded).length, + cacheTtlMs: this.#degradedCacheTtlMs, + durationMs: this.deps.clock.now() - startedAt, + }); + return stale; + } + const response: ListDashboardRecentProjectsResponse = { projects: mergeRecentProjectCandidates(successful), }; diff --git a/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts b/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts index ff0a0a39..53f1c49b 100644 --- a/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts +++ b/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts @@ -15,13 +15,16 @@ export class InMemoryRecentProjectsCache implements RecentProjectsCachePort { + return this.#entries.get(key)?.value ?? null; + } + async set(key: string, value: T, ttlMs: number): Promise { this.#entries.set(key, { value, diff --git a/src/renderer/components/sidebar/dateGroupedSessionsSelection.ts b/src/renderer/components/sidebar/dateGroupedSessionsSelection.ts index 4c0e806d..36813567 100644 --- a/src/renderer/components/sidebar/dateGroupedSessionsSelection.ts +++ b/src/renderer/components/sidebar/dateGroupedSessionsSelection.ts @@ -11,17 +11,23 @@ export function resolveEffectiveSelectedRepositoryId({ selectedRepositoryId, effectiveSelectedWorktreeId, }: ResolveEffectiveSelectedRepositoryIdInput): string | null { - if (selectedRepositoryId) { + const worktreeOwnerRepositoryId = + effectiveSelectedWorktreeId == null + ? null + : (repositoryGroups.find((repo) => + repo.worktrees.some((worktree) => worktree.id === effectiveSelectedWorktreeId) + )?.id ?? null); + + if (worktreeOwnerRepositoryId) { + return worktreeOwnerRepositoryId; + } + + if ( + selectedRepositoryId && + repositoryGroups.some((repositoryGroup) => repositoryGroup.id === selectedRepositoryId) + ) { return selectedRepositoryId; } - if (!effectiveSelectedWorktreeId) { - return null; - } - - return ( - repositoryGroups.find((repo) => - repo.worktrees.some((worktree) => worktree.id === effectiveSelectedWorktreeId) - )?.id ?? null - ); + return null; } diff --git a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts index 11273746..8ab8963a 100644 --- a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts +++ b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts @@ -50,6 +50,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { const cached: TestViewModel = { ids: ['cached'], sources: ['cached'] }; const cache: RecentProjectsCachePort = { get: vi.fn().mockResolvedValue(cached), + getStale: vi.fn().mockResolvedValue(cached), set: vi.fn(), }; const output: ListDashboardRecentProjectsOutputPort = { @@ -77,6 +78,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { it('merges successful sources, degrades failed sources, and caches presenter output', async () => { const cache: RecentProjectsCachePort = { get: vi.fn().mockResolvedValue(null), + getStale: vi.fn().mockResolvedValue(null), set: vi.fn().mockResolvedValue(undefined), }; const output: ListDashboardRecentProjectsOutputPort = { @@ -172,6 +174,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { try { const cache: RecentProjectsCachePort = { get: vi.fn().mockResolvedValue(null), + getStale: vi.fn().mockResolvedValue(null), set: vi.fn().mockResolvedValue(undefined), }; const output: ListDashboardRecentProjectsOutputPort = { @@ -244,4 +247,61 @@ describe('ListDashboardRecentProjectsUseCase', () => { vi.useRealTimers(); } }); + + it('returns stale cached data when a source degrades after cache expiry', async () => { + const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] }; + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(null), + getStale: vi.fn().mockResolvedValue(stale), + set: vi.fn().mockResolvedValue(undefined), + }; + const output: ListDashboardRecentProjectsOutputPort = { + present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({ + ids: response.projects.map((project) => project.identity), + sources: response.projects.map((project) => project.source), + })), + }; + const sources: RecentProjectsSourcePort[] = [ + { + sourceId: 'claude', + list: vi.fn().mockResolvedValue([ + makeCandidate({ + identity: 'repo:fresh', + providerIds: ['anthropic'], + sourceKind: 'claude', + }), + ]), + }, + { + sourceId: 'codex', + list: vi.fn().mockRejectedValue(new Error('codex unavailable')), + }, + ]; + const logger = createLogger(); + let now = 15_000; + + const useCase = new ListDashboardRecentProjectsUseCase({ + sources, + cache, + output, + clock: { + now: () => { + const current = now; + now += 200; + return current; + }, + }, + logger, + }); + + await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale); + expect(output.present).not.toHaveBeenCalled(); + expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 1_500); + expect(logger.info).toHaveBeenCalledWith('recent-projects served stale cache', { + cacheKey: 'recent-projects:stale', + degradedSources: 1, + cacheTtlMs: 1_500, + durationMs: 200, + }); + }); }); diff --git a/test/renderer/components/sidebar/dateGroupedSessionsSelection.test.ts b/test/renderer/components/sidebar/dateGroupedSessionsSelection.test.ts index 63fda15d..54c6a278 100644 --- a/test/renderer/components/sidebar/dateGroupedSessionsSelection.test.ts +++ b/test/renderer/components/sidebar/dateGroupedSessionsSelection.test.ts @@ -50,4 +50,42 @@ describe('resolveEffectiveSelectedRepositoryId', () => { }) ).toBe('repo-headless'); }); + + it('falls back to the worktree owner when the explicit repository selection is stale', () => { + const repositoryGroups = [ + { + id: 'repo-headless', + worktrees: [{ id: 'worktree-headless', path: '/Users/belief/dev/projects/headless' }], + }, + ] as const; + + expect( + resolveEffectiveSelectedRepositoryId({ + repositoryGroups, + selectedRepositoryId: 'repo-stale', + effectiveSelectedWorktreeId: 'worktree-headless', + }) + ).toBe('repo-headless'); + }); + + it('prefers the repository that owns the active worktree over a different valid repository', () => { + const repositoryGroups = [ + { + id: 'repo-headless', + worktrees: [{ id: 'worktree-headless', path: '/Users/belief/dev/projects/headless' }], + }, + { + id: 'repo-other', + worktrees: [{ id: 'worktree-other', path: '/Users/belief/dev/projects/other' }], + }, + ] as const; + + expect( + resolveEffectiveSelectedRepositoryId({ + repositoryGroups, + selectedRepositoryId: 'repo-other', + effectiveSelectedWorktreeId: 'worktree-headless', + }) + ).toBe('repo-headless'); + }); });