From efb281685450ef6810f8ff57f3f3e03c19795615 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 5 Aug 2026 11:40:44 +1000 Subject: [PATCH 1/3] fix(branches): keep behind-count and Rebase button visible during git refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch card header zeroed `parentAheadCount` while a background git-state refresh was in flight, which hid both the "+N" pill and the Rebase button until the fetch completed — several seconds per repo on project switch, despite a perfectly good last-known count already being cached. Pass the cached `commitsSinceFork` through unconditionally. The separate spinner still signals that a refresh is running, and the count reconciles (or the pill and button slide away) when the `git-state-updated` event lands. The genuine placeholder path from the fast timeline stream still reports 0, so first-ever loads render nothing until the fetch finishes, as before. While refreshing, dim the count and extend the capsule tooltip with "(checking for updates…)" so the value reads as provisional. Signed-off-by: Matt Toohey --- .../src/lib/features/branches/BranchCard.svelte | 2 +- .../features/branches/BranchCardHeaderInfo.svelte | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index eba9597af..37ae935a1 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -1702,7 +1702,7 @@ baseBranch={isRemote ? (branch.workspaceName ?? formatBaseBranch(branch.baseBranch)) : formatBaseBranch(branch.baseBranch)} - parentAheadCount={refreshingGitState ? 0 : (timeline?.gitState?.base.commitsSinceFork ?? 0)} + parentAheadCount={timeline?.gitState?.base.commitsSinceFork ?? 0} onRebase={branchCommandDisabledReason ? undefined : () => startBranchCommandPipeline('rebase')} diff --git a/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte b/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte index 382a9cbe8..25d06e62c 100644 --- a/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte @@ -33,12 +33,21 @@ refreshingGitState = false, fetchError = null, }: Props = $props(); + + const capsuleTitle = $derived.by(() => { + if (!baseBranch || parentAheadCount <= 0) return baseBranch ?? undefined; + const behind = `${parentAheadCount} commit${parentAheadCount === 1 ? '' : 's'} behind`; + return refreshingGitState + ? `${baseBranch} · ${behind} (checking for updates…)` + : `${baseBranch} · ${behind}`; + }); {#snippet capsule()} - + {baseBranch}{#if parentAheadCount > 0} +{parentAheadCount} Date: Wed, 5 Aug 2026 12:14:11 +1000 Subject: [PATCH 2/3] fix(branches): clear git-refresh flag when the command settles without an event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh_branch_git_state_impl returns Ok without emitting git-state-updated when the branch has no workdir or its worktree path no longer exists. For such branches the refreshingGitState flag never cleared, leaving a perpetual spinner — and, since the previous commit, a permanently dimmed behind-count with a stale "(checking for updates…)" tooltip. Clear the flag when the refresh promise settles instead of only on rejection. Both the desktop command and the web-mode dispatch resolve only after the impl — including the emit, when there is one — has completed, so on the happy path the event still lands with (or just after) settlement and merges the fresh state; on the no-workdir arms the spinner and dimming now stop instead of sticking forever. The event listener remains for refreshes triggered outside the card (bulk refresh on project open, post-push force refresh). Signed-off-by: Matt Toohey --- .../lib/features/branches/BranchCard.svelte | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index 37ae935a1..c9f741dda 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -567,9 +567,14 @@ // Kick off a background git-state refresh (TTL-gated fetch). refreshingGitState = true; - commands.refreshBranchGitState(branch.id).catch(() => { - refreshingGitState = false; - }); + commands + .refreshBranchGitState(branch.id) + .catch(() => {}) + .finally(() => { + // Clear on settle, not just on the `git-state-updated` event: branches + // with no workdir (or a deleted worktree) resolve Ok without emitting. + refreshingGitState = false; + }); } // Synchronously hydrate timeline from cache so isSettingUp is never true @@ -802,9 +807,14 @@ // Kick off a background git-state refresh (TTL-gated fetch). // The result arrives via the `git-state-updated` event listener above. refreshingGitState = true; - commands.refreshBranchGitState(branch.id).catch(() => { - refreshingGitState = false; - }); + commands + .refreshBranchGitState(branch.id) + .catch(() => {}) + .finally(() => { + // Clear on settle, not just on the `git-state-updated` event: branches + // with no workdir (or a deleted worktree) resolve Ok without emitting. + refreshingGitState = false; + }); } function getTimelineReviewDetails(fullReview: TimelineFullReview): TimelineReviewDetails { From bb2b85c9619e7bc8f81b2570720df885d9f68d10 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 5 Aug 2026 13:09:44 +1000 Subject: [PATCH 3/3] fix(branches): version-gate the git-refresh flag against overlapping refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two card-initiated git-state refreshes can overlap: the mount-time hydration path in applyCachedTimeline can still be mid-fetch when a session completion or timeline-invalidated event re-runs loadTimeline and kicks off a second call. The first refresh to finish then ended the spinner and the provisional dimming early — via its .finally() and via its git-state-updated event, whose listener also cleared the flag unconditionally. Extract the duplicated kick-off block into startGitStateRefresh() and guard the settle-time clear with a generation counter, matching the revalidationVersion pattern already used for timeline revalidation: only the newest refresh's settlement clears the flag. Drop the clear from the event listener — settlement-based clearing covers every card-initiated refresh (the command resolves only after the emit, verified in the previous commit), and an event from an external refresh (bulk refresh on project open, post-push force refresh) must not end the spinner while this card's own refresh is still in flight. The listener still merges the fresh git state. Signed-off-by: Matt Toohey --- .../lib/features/branches/BranchCard.svelte | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index c9f741dda..0019cd525 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -520,6 +520,30 @@ let requestedTimelineKey: string | null = null; let timelineLoadVersion = 0; let revalidationVersion = 0; + let gitRefreshVersion = 0; + + /** + * Kick off a background git-state refresh (TTL-gated fetch). Fresh state + * arrives via the `git-state-updated` event listener below. + * + * Clear the flag on settle, not just on the event: branches with no workdir + * (or a deleted worktree) resolve Ok without emitting. Version-gated so an + * older refresh settling can't end the spinner while a newer one — e.g. a + * `loadTimeline` re-run overlapping the mount-time hydration refresh — is + * still in flight. + */ + function startGitStateRefresh() { + const version = ++gitRefreshVersion; + refreshingGitState = true; + commands + .refreshBranchGitState(branch.id) + .catch(() => {}) + .finally(() => { + if (version === gitRefreshVersion) { + refreshingGitState = false; + } + }); + } function isCurrentTimelineLoad(loadVersion: number, timelineKey: string): boolean { return loadVersion === timelineLoadVersion && branchTimelineReadyKey(branch) === timelineKey; @@ -565,16 +589,7 @@ void loadTimelineReviewDetails(cached.reviews); } - // Kick off a background git-state refresh (TTL-gated fetch). - refreshingGitState = true; - commands - .refreshBranchGitState(branch.id) - .catch(() => {}) - .finally(() => { - // Clear on settle, not just on the `git-state-updated` event: branches - // with no workdir (or a deleted worktree) resolve Ok without emitting. - refreshingGitState = false; - }); + startGitStateRefresh(); } // Synchronously hydrate timeline from cache so isSettingUp is never true @@ -702,7 +717,10 @@ if (timeline) { timeline = { ...timeline, gitState: payload.gitState }; } - refreshingGitState = false; + // `refreshingGitState` is cleared by startGitStateRefresh's settle + // handler, not here: an event from an external refresh (e.g. the bulk + // refresh on project open) must not end the spinner while this card's + // own refresh is still in flight. }); return () => { @@ -804,17 +822,7 @@ } } - // Kick off a background git-state refresh (TTL-gated fetch). - // The result arrives via the `git-state-updated` event listener above. - refreshingGitState = true; - commands - .refreshBranchGitState(branch.id) - .catch(() => {}) - .finally(() => { - // Clear on settle, not just on the `git-state-updated` event: branches - // with no workdir (or a deleted worktree) resolve Ok without emitting. - refreshingGitState = false; - }); + startGitStateRefresh(); } function getTimelineReviewDetails(fullReview: TimelineFullReview): TimelineReviewDetails {