diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 464fb650..cafe741c 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -293,9 +293,8 @@ type TimelineJob = { }; const TIMELINE_MAX_CONCURRENCY = 1; -const TIMELINE_CACHE_MAX_AGE_MS = 30_000; const timelineQueue: TimelineJob[] = []; -const timelineCache = new Map(); +const timelineCache = new Map(); const inFlightTimelines = new Map>(); let activeTimelineJobs = 0; @@ -307,7 +306,7 @@ function pumpTimelineQueue() { activeTimelineJobs += 1; invoke('get_branch_timeline', { branchId: job.branchId }) .then((timeline) => { - timelineCache.set(job.branchId, { timeline, fetchedAt: Date.now() }); + timelineCache.set(job.branchId, { timeline }); job.resolve(timeline); }) .catch((error) => { @@ -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..b014d583 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,45 @@ void loadTimeline(); }); + let revalidationVersion = 0; + async function loadTimeline() { const isInitialLoad = !timeline; + error = null; + // Cancel any in-flight revalidation so it can't overwrite fresher data + revalidationVersion++; + if (isInitialLoad) { + const { cached, fresh } = commands.getBranchTimelineWithRevalidation(branch.id); + if (cached) { + // Show stale data immediately + timeline = cached; + loading = false; + prunedSessionIds = sessionMgr.prunePendingSessionItems(cached); + revalidating = true; + const version = revalidationVersion; + fresh + .then((next) => { + if (version !== revalidationVersion) return; + error = null; + 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; @@ -715,7 +749,7 @@ Loading... - {:else if error} + {:else if error && !timeline}
{error}
@@ -726,6 +760,7 @@ pendingDropNotes={isLocal ? pendingDropNotes : undefined} pendingItems={sessionMgr.pendingSessionItems} {prunedSessionIds} + {revalidating} deletingItems={timelineDeletingItems} reviewCommentBreakdown={timelineReviewDetailsById} onSessionClick={(sid) => sessionMgr.handleTimelineSessionClick(sid)} @@ -772,6 +807,11 @@ {/if} {/snippet}
+ {#if error} +
+ {error} +
+ {/if} {/if} {/if} @@ -1026,6 +1066,11 @@ font-size: var(--size-sm); } + .revalidation-error { + padding: 4px 12px; + color: var(--text-faint); + } + /* Worktree error state */ .worktree-error { display: flex; diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index eea84b46..3d23b9ca 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); } @@ -583,6 +584,7 @@ existing.filter((b) => b.id !== branch.id) ); } + commands.invalidateBranchTimeline(branch.id); } catch (e) { console.error('Failed to delete branch:', e); } finally { diff --git a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte index 54de388e..572e9415 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(); @@ -458,7 +461,9 @@ isLast={index === items.length - 1 && !onNewNote && !onNewCommit && - pendingDropNotes.length === 0} + pendingDropNotes.length === 0 && + pendingItems.length === 0 && + !revalidating} sessionId={item.sessionId} deleteDisabledReason={item.deleteDisabledReason} {onSessionClick} @@ -475,6 +480,7 @@ secondaryMeta="adding..." isLast={index === pendingDropNotes.length - 1 && pendingItems.length === 0 && + !revalidating && !onNewNote && !onNewCommit} /> @@ -491,10 +497,15 @@ fallbackHintForPendingType(item.type)) : item.secondaryMeta} sessionId={item.sessionId} - isLast={index === pendingItems.length - 1 && !onNewNote && !onNewCommit} + isLast={index === pendingItems.length - 1 && !revalidating && !onNewNote && !onNewCommit} /> {/each} + {#if revalidating} +
+ +
+ {/if} {#if onNewNote || onNewCommit || onNewReview || footerActions}