Skip to content
Merged
24 changes: 20 additions & 4 deletions apps/staged/src/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { timeline: BranchTimeline; fetchedAt: number }>();
const timelineCache = new Map<string, { timeline: BranchTimeline }>();
const inFlightTimelines = new Map<string, Promise<BranchTimeline>>();
let activeTimelineJobs = 0;

Expand All @@ -307,7 +306,7 @@ function pumpTimelineQueue() {
activeTimelineJobs += 1;
invoke<BranchTimeline>('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) => {
Expand All @@ -334,7 +333,7 @@ export function getBranchTimeline(
): Promise<BranchTimeline> {
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);
}
}
Expand All @@ -357,6 +356,23 @@ export function getBranchTimeline(
return request;
}

export function getBranchTimelineWithRevalidation(branchId: string): {
cached: BranchTimeline | null;
fresh: Promise<BranchTimeline>;
} {
const entry = timelineCache.get(branchId);
return {
cached: entry?.timeline ?? null,
fresh: getBranchTimeline(branchId, { force: true }),
Comment on lines +364 to +366

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reusing mount-time refresh for later forced timeline loads

getBranchTimelineWithRevalidation() starts a forced fetch immediately, and getBranchTimeline() still deduplicates by branch ID against any in-flight request. That means if the user starts a note/commit while this initial refresh is still running, the later loadTimeline() calls that are meant to show the new pending stub immediately (see the session-status-changed handler in BranchCard.svelte) collapse onto the older request that was issued before the new session existed. On slower timelines, the card can therefore stay stuck on the pre-action state until some later refresh happens.

Useful? React with 👍 / 👎.

};
}

export function invalidateProjectBranchTimelines(branchIds: string[]): void {
for (const id of branchIds) {
timelineCache.delete(id);
}
}

// =============================================================================
// Actions
// =============================================================================
Expand Down
49 changes: 47 additions & 2 deletions apps/staged/src/lib/features/branches/BranchCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@

let timeline = $state<BranchTimelineData | null>(null);
let loading = $state(true);
let revalidating = $state(false);
let error = $state<string | null>(null);
let showBranchDiff = $state(false);
let loadedTimelineKey = $state<string | null>(null);
Expand Down Expand Up @@ -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);
Comment on lines +309 to +311

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep stale timeline visible when background refresh fails

In the stale-while-revalidate path, a transient failure now assigns error even though timeline still holds the cached data. Because this component renders {:else if error} before {:else if timeline} later in BranchCard.svelte, opening a previously cached branch while offline (or during any backend hiccup) replaces the usable stale timeline with an error panel instead of preserving the fallback content.

Useful? React with 👍 / 👎.

})
.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;
Expand Down Expand Up @@ -715,7 +749,7 @@
<Spinner size={14} />
<span>Loading...</span>
</div>
{:else if error}
{:else if error && !timeline}
<div class="error">
<span>{error}</span>
</div>
Expand All @@ -726,6 +760,7 @@
pendingDropNotes={isLocal ? pendingDropNotes : undefined}
pendingItems={sessionMgr.pendingSessionItems}
{prunedSessionIds}
{revalidating}
deletingItems={timelineDeletingItems}
reviewCommentBreakdown={timelineReviewDetailsById}
onSessionClick={(sid) => sessionMgr.handleTimelineSessionClick(sid)}
Expand Down Expand Up @@ -772,6 +807,11 @@
{/if}
{/snippet}
</BranchTimeline>
{#if error}
<div class="error revalidation-error">
<span>{error}</span>
</div>
{/if}
{/if}
</div>
{/if}
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions apps/staged/src/lib/features/projects/ProjectHome.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 13 additions & 2 deletions apps/staged/src/lib/features/timeline/BranchTimeline.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -88,6 +90,7 @@
onNewCommit,
onNewReview,
newSessionDisabled = false,
revalidating = false,
footerActions,
}: Props = $props();

Expand Down Expand Up @@ -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}
Expand All @@ -475,6 +480,7 @@
secondaryMeta="adding..."
isLast={index === pendingDropNotes.length - 1 &&
pendingItems.length === 0 &&
!revalidating &&
!onNewNote &&
!onNewCommit}
/>
Expand All @@ -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}
/>
</div>
{/each}
{#if revalidating}
<div transition:slide={{ duration: 200 }}>
<TimelineRow type="revalidating" title="Looking for changes..." isLast={true} />
</div>
{/if}
{#if onNewNote || onNewCommit || onNewReview || footerActions}
<div class="footer-row">
<div class="footer-left-actions">
Expand Down
25 changes: 23 additions & 2 deletions apps/staged/src/lib/features/timeline/TimelineRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
| 'review'
| 'generating-review'
| 'failed-review'
| 'image';
| 'image'
| 'revalidating';

export type TimelineBadge = {
icon: 'comment' | 'warning';
Expand Down Expand Up @@ -73,7 +74,8 @@
deleting ||
type === 'pending-commit' ||
type === 'generating-note' ||
type === 'generating-review'
type === 'generating-review' ||
type === 'revalidating'
);
let isFailed = $derived(
!deleting && (type === 'failed-commit' || type === 'failed-note' || type === 'failed-review')
Expand Down Expand Up @@ -107,6 +109,7 @@
class:pending={isPending}
class:failed={isFailed}
class:clickable={isClickable}
class:compact={type === 'revalidating'}
onclick={handleRowClick}
>
<div class="timeline-marker">
Expand Down Expand Up @@ -209,6 +212,10 @@
cursor: default;
}

.timeline-row.compact {
padding: 6px 8px;
}

.timeline-row.failed {
cursor: default;
}
Expand Down Expand Up @@ -300,6 +307,20 @@
color: var(--review-color);
}

.timeline-row.compact .timeline-icon {
background-color: var(--bg-hover);
border-color: var(--bg-hover);
}

.timeline-row.compact .timeline-icon :global(.spinner) {
color: var(--text-faint);
}

.timeline-row.compact .timeline-title {
color: var(--text-faint);
font-weight: normal;
}

.timeline-icon.failed-icon {
color: var(--text-muted);
border-color: var(--border-muted);
Expand Down