From 67882bf81c00598b9c0161cbebe6274004911b72 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 20 Mar 2026 15:02:43 +1100 Subject: [PATCH 1/9] feat(ui): persistent timeline cache with stale-while-revalidate Remove the 30s TTL from the timeline cache so cached data never expires on its own. When switching back to a project, show cached timeline data immediately and display a "Looking for changes..." spinner row at the bottom while revalidating in the background. - Remove TIMELINE_CACHE_MAX_AGE_MS; cache entries persist until explicitly invalidated - Add getBranchTimelineWithRevalidation() that returns cached data and kicks off a background re-fetch - Add invalidateProjectBranchTimelines() for batch cache eviction - Update BranchCard.loadTimeline() to use stale-while-revalidate on initial load with a version counter to discard stale results - Add revalidating prop to BranchTimeline with a pending-commit style "Looking for changes..." row - Invalidate cache entries on project and branch deletion Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/staged/src/lib/commands.ts | 20 +++++++++-- .../lib/features/branches/BranchCard.svelte | 33 ++++++++++++++++++- .../lib/features/projects/ProjectHome.svelte | 2 ++ .../features/timeline/BranchTimeline.svelte | 10 ++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 464fb650..28468059 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -293,7 +293,6 @@ type TimelineJob = { }; const TIMELINE_MAX_CONCURRENCY = 1; -const TIMELINE_CACHE_MAX_AGE_MS = 30_000; const timelineQueue: TimelineJob[] = []; const timelineCache = new Map(); const inFlightTimelines = new Map>(); @@ -334,7 +333,7 @@ export function getBranchTimeline( ): Promise { if (!force) { const cached = timelineCache.get(branchId); - if (cached && Date.now() - cached.fetchedAt <= TIMELINE_CACHE_MAX_AGE_MS) { + if (cached) { return Promise.resolve(cached.timeline); } } @@ -357,6 +356,23 @@ export function getBranchTimeline( return request; } +export function getBranchTimelineWithRevalidation(branchId: string): { + cached: BranchTimeline | null; + fresh: Promise; +} { + const entry = timelineCache.get(branchId); + return { + cached: entry?.timeline ?? null, + fresh: getBranchTimeline(branchId, { force: true }), + }; +} + +export function invalidateProjectBranchTimelines(branchIds: string[]): void { + for (const id of branchIds) { + timelineCache.delete(id); + } +} + // ============================================================================= // Actions // ============================================================================= diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index e16fdffe..0db397f9 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -90,6 +90,7 @@ let timeline = $state(null); let loading = $state(true); + let revalidating = $state(false); let error = $state(null); let showBranchDiff = $state(false); let loadedTimelineKey = $state(null); @@ -283,12 +284,41 @@ void loadTimeline(); }); + let revalidationVersion = 0; + async function loadTimeline() { const isInitialLoad = !timeline; + error = null; + if (isInitialLoad) { + const { cached, fresh } = commands.getBranchTimelineWithRevalidation(branch.id); + if (cached) { + // Show stale data immediately + timeline = cached; + prunedSessionIds = sessionMgr.prunePendingSessionItems(cached); + revalidating = true; + const version = ++revalidationVersion; + fresh + .then((next) => { + if (version !== revalidationVersion) return; + timeline = next; + prunedSessionIds = sessionMgr.prunePendingSessionItems(next); + void loadTimelineReviewDetails(next.reviews); + }) + .catch((e) => { + if (version !== revalidationVersion) return; + error = e instanceof Error ? e.message : String(e); + }) + .finally(() => { + if (version !== revalidationVersion) return; + revalidating = false; + }); + return; + } + // No cache — show loading spinner as before loading = true; } - error = null; + try { const nextTimeline = await commands.getBranchTimeline(branch.id, { force: !isInitialLoad }); timeline = nextTimeline; @@ -726,6 +756,7 @@ pendingDropNotes={isLocal ? pendingDropNotes : undefined} pendingItems={sessionMgr.pendingSessionItems} {prunedSessionIds} + {revalidating} deletingItems={timelineDeletingItems} reviewCommentBreakdown={timelineReviewDetailsById} onSessionClick={(sid) => sessionMgr.handleTimelineSessionClick(sid)} diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index eea84b46..48592599 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -448,6 +448,7 @@ if (repo.projectId === id) nextRepos.delete(repoId); } reposById = nextRepos; + commands.invalidateProjectBranchTimelines(branchesToClear.map((b) => b.id)); for (const branch of branchesToClear) { workspaceLifecycle.clearBranchState(branch.id); } @@ -589,6 +590,7 @@ const next = new Set(deletingBranches); next.delete(branch.id); deletingBranches = next; + commands.invalidateBranchTimeline(branch.id); workspaceLifecycle.clearBranchState(branch.id); } } diff --git a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte index 54de388e..7eebecc4 100644 --- a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte +++ b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte @@ -63,6 +63,8 @@ onNewCommit?: () => void; onNewReview?: (e: MouseEvent) => void; newSessionDisabled?: boolean; + /** Whether the timeline is being revalidated in the background. */ + revalidating?: boolean; footerActions?: Snippet; } @@ -88,6 +90,7 @@ onNewCommit, onNewReview, newSessionDisabled = false, + revalidating = false, footerActions, }: Props = $props(); @@ -495,6 +498,13 @@ /> {/each} + {#if revalidating} + + {/if} {#if onNewNote || onNewCommit || onNewReview || footerActions} {/each} {#if revalidating} - + {/if} {#if onNewNote || onNewCommit || onNewReview || footerActions} {/if} @@ -1058,6 +1066,11 @@ font-size: var(--size-sm); } + .revalidation-error { + padding: 4px 12px; + color: var(--text-faint); + } + /* Worktree error state */ .worktree-error { display: flex;