From 2a76f800eb02674a6aa04adafe94e7f195136984 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 11 May 2026 12:54:10 +1000 Subject: [PATCH 1/3] fix: eliminate timeline disruption when adopting auto-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to prevent the multi-second timeline gap when clicking "New Review" to adopt an auto-review: 1. Add `skipFetch` param to `get_branch_timeline` — when true, uses `FetchMode::Never` instead of `FetchMode::Ttl`, avoiding slow git fetch on session completion and auto-review adoption reloads. 2. Optimistically update the local timeline to set `isAuto: false` on the adopted review before the backend reload, so it renders instantly. 3. Filter session completion events: skip reload for adopted auto-review sessions (tracked via `adoptedSessionId`), and skip reload when the completing session belongs to a different branch. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/timeline.rs | 35 ++++++++++++------- apps/staged/src/lib/commands.ts | 8 +++-- .../lib/features/branches/BranchCard.svelte | 21 +++++++++-- .../BranchCardSessionManager.svelte.ts | 25 +++++++++++-- 4 files changed, 69 insertions(+), 20 deletions(-) diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index 635208580..a4b8f7215 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -135,6 +135,7 @@ fn build_branch_timeline( store: &Arc, branch_id: &str, app: Option<&tauri::AppHandle>, + fetch_mode: git::FetchMode, ) -> Result { // Get the branch and its workdir for git operations let branch = store @@ -161,7 +162,7 @@ fn build_branch_timeline( let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; let base_ref = git::origin_ref_for_branch(&branch.base_branch); - if app.is_some() && git::needs_fetch(&cache_key, git::FetchMode::Ttl) { + if app.is_some() && git::needs_fetch(&cache_key, fetch_mode) { // Two-stream: run fast + slow scripts concurrently. // The fast script returns local state + commits in one round-trip. // The slow script performs fetch + ref comparisons in another. @@ -176,7 +177,7 @@ fn build_branch_timeline( &resolved_path, &branch.branch_name, &branch.base_branch, - git::FetchMode::Ttl, + fetch_mode, ) }); @@ -229,7 +230,7 @@ fn build_branch_timeline( &resolved_path, &branch.branch_name, &branch.base_branch, - git::FetchMode::Ttl, + fetch_mode, )); commits = fetch_remote_commits( ws_name, @@ -250,7 +251,7 @@ fn build_branch_timeline( &branch.base_branch, ); - if app.is_some() && git::needs_fetch(&cache_key, git::FetchMode::Ttl) { + if app.is_some() && git::needs_fetch(&cache_key, fetch_mode) { // Two-stream: fast state + commits → emit partial → slow state let fast = git::compute_fast_local_git_state(worktree_path, &branch.branch_name); let git_commits = @@ -277,7 +278,7 @@ fn build_branch_timeline( &fast, &branch.branch_name, &branch.base_branch, - git::FetchMode::Ttl, + fetch_mode, )); } else { // Single stream: fetch cache is fresh @@ -285,7 +286,7 @@ fn build_branch_timeline( worktree_path, &branch.branch_name, &branch.base_branch, - git::FetchMode::Ttl, + fetch_mode, )); let git_commits = git::get_commits_since_base(worktree_path, &base_ref).map_err(|e| { @@ -446,16 +447,22 @@ fn review_is_visible_in_timeline(review: &Review, visible_shas: &HashSet<&str>) .any(|comment| comment.author == CommentAuthor::User) } -#[tauri::command] +#[tauri::command(rename_all = "camelCase")] pub async fn get_branch_timeline( app: tauri::AppHandle, store: tauri::State<'_, Mutex>>>, branch_id: String, + skip_fetch: Option, ) -> Result { let store = crate::get_store(&store)?; + let fetch_mode = if skip_fetch.unwrap_or(false) { + git::FetchMode::Never + } else { + git::FetchMode::Ttl + }; tauri::async_runtime::spawn_blocking(move || { - build_branch_timeline(&store, &branch_id, Some(&app)) + build_branch_timeline(&store, &branch_id, Some(&app), fetch_mode) }) .await .map_err(|e| format!("Timeline task failed: {e}"))? @@ -969,7 +976,8 @@ mod tests { store.create_review(&visible_review).unwrap(); store.create_review(&stale_review).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); + let timeline = + build_branch_timeline(&store, &branch.id, None, git::FetchMode::Ttl).unwrap(); assert_eq!(timeline.commits.len(), 1); assert_eq!(timeline.commits[0].sha, visible_sha); @@ -988,7 +996,8 @@ mod tests { store.create_review(&stale_review).unwrap(); store.add_comment(&stale_review.id, &agent_comment).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); + let timeline = + build_branch_timeline(&store, &branch.id, None, git::FetchMode::Ttl).unwrap(); assert_eq!(timeline.commits.len(), 1); assert!(timeline.reviews.is_empty()); @@ -1004,7 +1013,8 @@ mod tests { store.create_review(&stale_review).unwrap(); store.add_comment(&stale_review.id, &user_comment).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); + let timeline = + build_branch_timeline(&store, &branch.id, None, git::FetchMode::Ttl).unwrap(); assert_eq!(timeline.commits.len(), 1); assert_eq!(timeline.reviews.len(), 1); @@ -1023,7 +1033,8 @@ mod tests { store.add_comment(&stale_review.id, &user_comment).unwrap(); store.delete_comment(&user_comment.id).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); + let timeline = + build_branch_timeline(&store, &branch.id, None, git::FetchMode::Ttl).unwrap(); assert_eq!(timeline.commits.len(), 1); assert!(timeline.reviews.is_empty()); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index c03e6024f..c2c3818ec 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -345,11 +345,12 @@ export function invalidateBranchTimeline(branchId: string): void { interface GetBranchTimelineOptions { force?: boolean; + skipFetch?: boolean; } export function getBranchTimeline( branchId: string, - { force = false }: GetBranchTimelineOptions = {} + { force = false, skipFetch = false }: GetBranchTimelineOptions = {} ): Promise { if (!force) { const cached = timelineCache.get(branchId); @@ -363,7 +364,10 @@ export function getBranchTimeline( } } - const request = invoke('get_branch_timeline', { branchId }) + const request = invoke('get_branch_timeline', { + branchId, + skipFetch: skipFetch || undefined, + }) .then((timeline) => { if (inFlightTimelines.get(branchId) === request) { timelineCache.set(branchId, { timeline, fetchedAt: Date.now() }); diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index 4a10f5378..e5fd5def4 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -481,8 +481,11 @@ const sessionMgr = new BranchCardSessionManager({ getBranch: () => branch, getIsRemote: () => isRemote, - loadTimeline: () => loadTimeline(), + loadTimeline: (opts) => loadTimeline(opts), getTimeline: () => timeline, + setTimeline: (tl) => { + timeline = tl; + }, }); let requestedTimelineKey: string | null = null; @@ -625,8 +628,18 @@ return; } + // Skip reload for the adopted auto-review session completing — + // the timeline was already updated optimistically during adoption. + if (eventSessionId === sessionMgr.adoptedSessionId) { + sessionMgr.adoptedSessionId = null; + return; + } + + // Only reload if this session belongs to our branch + if (eventBranchId && eventBranchId !== branchId) return; + commands.invalidateBranchTimeline(branch.id); - loadTimeline(); + loadTimeline({ skipFetch: true }); // Handle PR session completion if (prButton && eventSessionId === prButton.getPrSessionId()) { prButton.handlePrSessionComplete(status); @@ -740,7 +753,8 @@ async function loadTimeline({ timelineKey = branchTimelineReadyKey(branch), force = false, - }: { timelineKey?: string | null; force?: boolean } = {}) { + skipFetch = false, + }: { timelineKey?: string | null; force?: boolean; skipFetch?: boolean } = {}) { if (!timelineKey) return; const loadVersion = ++timelineLoadVersion; @@ -768,6 +782,7 @@ const nextTimeline = await commands.getBranchTimeline(branch.id, { force: force || !isInitialLoad, + skipFetch, }); if (!isCurrentTimelineLoad(loadVersion, timelineKey)) return; timeline = nextTimeline; diff --git a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts index 4bc4c017d..88e4f1ced 100644 --- a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts +++ b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts @@ -38,8 +38,9 @@ export default class BranchCardSessionManager { // Private callback refs — declared first so $derived fields can reference them private getBranch: () => Branch = undefined!; private getIsRemote: () => boolean = undefined!; - private loadTimeline: () => void = undefined!; + private loadTimeline: (opts?: { skipFetch?: boolean }) => void = undefined!; private getTimeline: () => BranchTimelineData | null = () => null; + private setTimeline: (tl: BranchTimelineData) => void = undefined!; // New session modal state showNewSession = $state(false); @@ -52,6 +53,9 @@ export default class BranchCardSessionManager { // Auto review state — tracks a background review started after each commit autoReviewSessionId = $state(null); autoReviewId = $state(null); + // Tracks the session ID of an adopted auto-review so its completion event + // can be ignored (it would otherwise trigger a spurious timeline reload). + adoptedSessionId = $state(null); // Session modal (opened after starting a branch session, or from timeline) openSessionId = $state(null); @@ -95,13 +99,15 @@ export default class BranchCardSessionManager { constructor(opts: { getBranch: () => Branch; getIsRemote: () => boolean; - loadTimeline: () => void; + loadTimeline: (opts?: { skipFetch?: boolean }) => void; getTimeline: () => BranchTimelineData | null; + setTimeline: (tl: BranchTimelineData) => void; }) { this.getBranch = opts.getBranch; this.getIsRemote = opts.getIsRemote; this.loadTimeline = opts.loadTimeline; this.getTimeline = opts.getTimeline; + this.setTimeline = opts.setTimeline; } prunePendingSessionItems(nextTimeline: BranchTimelineData): Set { @@ -242,10 +248,23 @@ export default class BranchCardSessionManager { // Only reveal the review after all fallible operations succeed await commands.setReviewAuto(review.id, false); + // Optimistically update the local timeline so the review is visible + // immediately, before the backend reload completes. + const currentTimeline = this.getTimeline(); + if (currentTimeline) { + this.setTimeline({ + ...currentTimeline, + reviews: currentTimeline.reviews.map((r) => + r.id === review.id ? { ...r, isAuto: false } : r + ), + }); + } + + this.adoptedSessionId = this.autoReviewSessionId; this.autoReviewSessionId = null; this.autoReviewId = null; - this.loadTimeline(); + this.loadTimeline({ skipFetch: true }); return true; } catch (e) { console.error('[BranchCard] Failed to adopt auto review:', e); From b714b5173a52acd9cab5729616de70a83811fca2 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 11 May 2026 13:12:15 +1000 Subject: [PATCH 2/3] refactor: decouple git fetch from timeline loading Make `get_branch_timeline` always use `FetchMode::Never` so it returns instantly from locally-cached refs. The two-stream path (fast partial + slow fetch), the `timeline-partial` event, and the `skipFetch` parameter are all removed. A new `refresh_branch_git_state` Tauri command runs a TTL-gated `git fetch` + ref comparison and emits a `git-state-updated` event that the frontend merges into the existing timeline. This is triggered as a background task after each timeline load. Every `loadTimeline()` call is now fast with no conditional slow path, and git state rows show stale-but-present data instead of flashing away during fetch. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/lib.rs | 1 + apps/staged/src-tauri/src/timeline.rs | 279 +++++++----------- apps/staged/src/lib/commands.ts | 8 +- .../lib/features/branches/BranchCard.svelte | 87 ++---- .../BranchCardSessionManager.svelte.ts | 6 +- 5 files changed, 149 insertions(+), 232 deletions(-) diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 052619cff..209da5f87 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -1803,6 +1803,7 @@ pub fn run() { delete_action_context, // Timeline timeline::get_branch_timeline, + timeline::refresh_branch_git_state, timeline::pull_branch_ff_only, // Notes note_commands::create_note, diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index a4b8f7215..085c487c0 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -1,19 +1,12 @@ -//! Timeline — branch timeline construction and related delete commands. +//! Timeline — branch timeline construction and related commands. //! -//! When a fetch is needed (TTL expired), the timeline is built using a -//! **two-stream** approach: +//! `get_branch_timeline` always uses `FetchMode::Never` so it returns +//! instantly from locally-cached refs. Git state rows show stale-but-present +//! data until refreshed. //! -//! - **Fast stream**: local-only git commands (HEAD, branch, status, commits) -//! complete in <1ms (local) or ~2s (one remote round-trip). A partial timeline -//! event is emitted so the frontend can show commits and worktree state -//! immediately. -//! -//! - **Slow stream**: `git fetch` + ref comparisons. Runs concurrently with -//! the fast stream for remote projects. The full `BranchTimeline` returned -//! by the command includes the complete git state from this stream. -//! -//! When the fetch cache is fresh, everything runs as a single fast stream -//! and no partial event is emitted. +//! `refresh_branch_git_state` runs a TTL-gated `git fetch` + ref comparison +//! and emits a `git-state-updated` event that the frontend merges into the +//! existing timeline. use crate::git; use crate::session_runner; @@ -28,15 +21,11 @@ use std::path::Path; use std::sync::{Arc, Mutex}; use tauri::Emitter; -/// Payload for the `timeline-partial` event emitted by the fast stream. -/// Contains commits and a placeholder git state (worktree populated, -/// upstream/base set to loading defaults). The frontend merges this -/// into the existing timeline while waiting for the full result. +/// Payload for the `git-state-updated` event emitted by `refresh_branch_git_state`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct TimelinePartialPayload { +struct GitStateUpdatedPayload { branch_id: String, - commits: Vec, git_state: git::BranchGitState, } @@ -131,12 +120,7 @@ fn map_local_commits( .collect() } -fn build_branch_timeline( - store: &Arc, - branch_id: &str, - app: Option<&tauri::AppHandle>, - fetch_mode: git::FetchMode, -) -> Result { +fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result { // Get the branch and its workdir for git operations let branch = store .get_branch(branch_id) @@ -162,138 +146,40 @@ fn build_branch_timeline( let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; let base_ref = git::origin_ref_for_branch(&branch.base_branch); - if app.is_some() && git::needs_fetch(&cache_key, fetch_mode) { - // Two-stream: run fast + slow scripts concurrently. - // The fast script returns local state + commits in one round-trip. - // The slow script performs fetch + ref comparisons in another. - let slow_git_state = std::thread::scope(|s| { - let slow_handle = s.spawn(|| { - git::compute_branch_git_state_batched( - &cache_key, - |script, args| { - branches::run_workspace_shell(ws_name, script, args) - .map_err(|e| e.to_string()) - }, - &resolved_path, - &branch.branch_name, - &branch.base_branch, - fetch_mode, - ) - }); - - // Fast stream: local state + commits (no fetch) - if let Ok(fast_output) = git::compute_fast_git_state_batched( - &|script, args| { - branches::run_workspace_shell(ws_name, script, args) - .map_err(|e| e.to_string()) - }, - &resolved_path, - &branch.base_branch, - ) { - let (fast, commit_lines) = fast_output.into_fast_git_state(&branch.branch_name); - commits = parse_commit_lines(store, branch_id, &commit_lines); - let partial_state = - fast.into_placeholder_git_state(&branch.branch_name, &branch.base_branch); - if let Some(app) = app { - let _ = app.emit( - "timeline-partial", - TimelinePartialPayload { - branch_id: branch_id.to_string(), - commits: commits.clone(), - git_state: partial_state, - }, - ); - } - } - - slow_handle.join().expect("slow git state thread panicked") - }); - - // If fast stream failed to get commits, fall back to traditional path - if commits.is_empty() { - commits = fetch_remote_commits( - ws_name, - repo_subpath.as_deref(), - store, - branch_id, - &base_ref, - )?; - } - git_state = Some(slow_git_state); - } else { - // Single stream: fetch cache is fresh, everything is fast - git_state = Some(git::compute_branch_git_state_batched( - &cache_key, - |script, args| { - branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) - }, - &resolved_path, - &branch.branch_name, - &branch.base_branch, - fetch_mode, - )); - commits = fetch_remote_commits( - ws_name, - repo_subpath.as_deref(), - store, - branch_id, - &base_ref, - )?; - } + git_state = Some(git::compute_branch_git_state_batched( + &cache_key, + |script, args| { + branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) + }, + &resolved_path, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Never, + )); + commits = fetch_remote_commits( + ws_name, + repo_subpath.as_deref(), + store, + branch_id, + &base_ref, + )?; } else if let Some(ref wd) = workdir { // Local branch: fetch commits from the local worktree let worktree_path = Path::new(&wd.path); if worktree_path.exists() { let base_ref = git::origin_ref_for_branch(&branch.base_branch); - let cache_key = git::local_git_state_cache_key( + + git_state = Some(git::compute_local_branch_git_state( worktree_path, &branch.branch_name, &branch.base_branch, - ); - - if app.is_some() && git::needs_fetch(&cache_key, fetch_mode) { - // Two-stream: fast state + commits → emit partial → slow state - let fast = git::compute_fast_local_git_state(worktree_path, &branch.branch_name); - let git_commits = - git::get_commits_since_base(worktree_path, &base_ref).map_err(|e| { - format!("Failed to get commits since base for branch {branch_id}: {e:?}") - })?; - commits = map_local_commits(store, branch_id, &git_commits); - if let Some(app) = app { - let partial_state = fast - .clone() - .into_placeholder_git_state(&branch.branch_name, &branch.base_branch); - let _ = app.emit( - "timeline-partial", - TimelinePartialPayload { - branch_id: branch_id.to_string(), - commits: commits.clone(), - git_state: partial_state, - }, - ); - } - // Slow stream: fetch + ref comparisons - git_state = Some(git::complete_local_git_state( - worktree_path, - &fast, - &branch.branch_name, - &branch.base_branch, - fetch_mode, - )); - } else { - // Single stream: fetch cache is fresh - git_state = Some(git::compute_local_branch_git_state( - worktree_path, - &branch.branch_name, - &branch.base_branch, - fetch_mode, - )); - let git_commits = - git::get_commits_since_base(worktree_path, &base_ref).map_err(|e| { - format!("Failed to get commits since base for branch {branch_id}: {e:?}") - })?; - commits = map_local_commits(store, branch_id, &git_commits); - } + git::FetchMode::Never, + )); + let git_commits = + git::get_commits_since_base(worktree_path, &base_ref).map_err(|e| { + format!("Failed to get commits since base for branch {branch_id}: {e:?}") + })?; + commits = map_local_commits(store, branch_id, &git_commits); } } @@ -449,23 +335,86 @@ fn review_is_visible_in_timeline(review: &Review, visible_shas: &HashSet<&str>) #[tauri::command(rename_all = "camelCase")] pub async fn get_branch_timeline( - app: tauri::AppHandle, store: tauri::State<'_, Mutex>>>, branch_id: String, - skip_fetch: Option, ) -> Result { let store = crate::get_store(&store)?; - let fetch_mode = if skip_fetch.unwrap_or(false) { - git::FetchMode::Never - } else { - git::FetchMode::Ttl - }; + + tauri::async_runtime::spawn_blocking(move || build_branch_timeline(&store, &branch_id)) + .await + .map_err(|e| format!("Timeline task failed: {e}"))? +} + +/// Run a TTL-gated `git fetch` + git state recomputation for a branch, +/// then emit a `git-state-updated` event so the frontend can merge the +/// fresh state into the existing timeline. +#[tauri::command(rename_all = "camelCase")] +pub async fn refresh_branch_git_state( + app: tauri::AppHandle, + store: tauri::State<'_, Mutex>>>, + branch_id: String, +) -> Result<(), String> { + let store = crate::get_store(&store)?; tauri::async_runtime::spawn_blocking(move || { - build_branch_timeline(&store, &branch_id, Some(&app), fetch_mode) + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + + let workdir = store + .get_workdir_for_branch(&branch_id) + .map_err(|e| e.to_string())?; + + let git_state = if let Some(ref ws_name) = branch.workspace_name { + let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?; + let cache_key = remote_git_state_cache_key( + ws_name, + repo_subpath.as_deref(), + &branch.branch_name, + &branch.base_branch, + ); + let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; + + Some(git::compute_branch_git_state_batched( + &cache_key, + |script, args| { + branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) + }, + &resolved_path, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Ttl, + )) + } else if let Some(ref wd) = workdir { + let worktree_path = Path::new(&wd.path); + if worktree_path.exists() { + Some(git::compute_local_branch_git_state( + worktree_path, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Ttl, + )) + } else { + None + } + } else { + None + }; + + if let Some(state) = git_state { + let _ = app.emit( + "git-state-updated", + GitStateUpdatedPayload { + branch_id: branch_id.to_string(), + git_state: state, + }, + ); + } + Ok(()) }) .await - .map_err(|e| format!("Timeline task failed: {e}"))? + .map_err(|e| format!("Git state refresh task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] @@ -976,8 +925,7 @@ mod tests { store.create_review(&visible_review).unwrap(); store.create_review(&stale_review).unwrap(); - let timeline = - build_branch_timeline(&store, &branch.id, None, git::FetchMode::Ttl).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id).unwrap(); assert_eq!(timeline.commits.len(), 1); assert_eq!(timeline.commits[0].sha, visible_sha); @@ -996,8 +944,7 @@ mod tests { store.create_review(&stale_review).unwrap(); store.add_comment(&stale_review.id, &agent_comment).unwrap(); - let timeline = - build_branch_timeline(&store, &branch.id, None, git::FetchMode::Ttl).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id).unwrap(); assert_eq!(timeline.commits.len(), 1); assert!(timeline.reviews.is_empty()); @@ -1013,8 +960,7 @@ mod tests { store.create_review(&stale_review).unwrap(); store.add_comment(&stale_review.id, &user_comment).unwrap(); - let timeline = - build_branch_timeline(&store, &branch.id, None, git::FetchMode::Ttl).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id).unwrap(); assert_eq!(timeline.commits.len(), 1); assert_eq!(timeline.reviews.len(), 1); @@ -1033,8 +979,7 @@ mod tests { store.add_comment(&stale_review.id, &user_comment).unwrap(); store.delete_comment(&user_comment.id).unwrap(); - let timeline = - build_branch_timeline(&store, &branch.id, None, git::FetchMode::Ttl).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id).unwrap(); assert_eq!(timeline.commits.len(), 1); assert!(timeline.reviews.is_empty()); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index c2c3818ec..5e57b990c 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -345,12 +345,11 @@ export function invalidateBranchTimeline(branchId: string): void { interface GetBranchTimelineOptions { force?: boolean; - skipFetch?: boolean; } export function getBranchTimeline( branchId: string, - { force = false, skipFetch = false }: GetBranchTimelineOptions = {} + { force = false }: GetBranchTimelineOptions = {} ): Promise { if (!force) { const cached = timelineCache.get(branchId); @@ -366,7 +365,6 @@ export function getBranchTimeline( const request = invoke('get_branch_timeline', { branchId, - skipFetch: skipFetch || undefined, }) .then((timeline) => { if (inFlightTimelines.get(branchId) === request) { @@ -396,6 +394,10 @@ export function getBranchTimelineWithRevalidation(branchId: string): { }; } +export function refreshBranchGitState(branchId: string): Promise { + return invoke('refresh_branch_git_state', { branchId }); +} + export function invalidateProjectBranchTimelines(branchIds: string[]): void { for (const id of branchIds) { timelineCache.delete(id); diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index e5fd5def4..a572d0657 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -28,7 +28,6 @@ Branch, BranchGitState, BranchTimeline as BranchTimelineData, - CommitTimelineItem, HashtagItem, ProjectRepo, SessionStatusPayload, @@ -127,8 +126,8 @@ let timeline = $state(null); let loading = $state(true); - /** True from the start of any timeline load until the full (slow) git state arrives. */ - let refreshingGitState = $state(true); + /** True while a background git-state refresh (fetch + ref comparison) is in flight. */ + let refreshingGitState = $state(false); let error = $state(null); let pullingOrigin = $state(false); let discardingWorktreeChanges = $state(false); @@ -514,7 +513,6 @@ loading = false; prunedSessionIds = sessionMgr.prunePendingSessionItems(cached); if (fresh) { - refreshingGitState = true; const version = ++revalidationVersion; fresh .then((next) => { @@ -532,17 +530,16 @@ return; } error = e instanceof Error ? e.message : String(e); - }) - .finally(() => { - if (version !== revalidationVersion || branchTimelineReadyKey(branch) !== timelineKey) { - return; - } - refreshingGitState = false; }); } else { - refreshingGitState = false; void loadTimelineReviewDetails(cached.reviews); } + + // Kick off a background git-state refresh (TTL-gated fetch). + refreshingGitState = true; + commands.refreshBranchGitState(branch.id).catch(() => { + refreshingGitState = false; + }); } // Synchronously hydrate timeline from cache so isSettingUp is never true @@ -639,7 +636,7 @@ if (eventBranchId && eventBranchId !== branchId) return; commands.invalidateBranchTimeline(branch.id); - loadTimeline({ skipFetch: true }); + loadTimeline(); // Handle PR session completion if (prButton && eventSessionId === prButton.getPrSessionId()) { prButton.handlePrSessionComplete(status); @@ -676,55 +673,24 @@ }; }); - // Listen for partial timeline events emitted by the fast stream. - // When the backend needs to fetch, it emits commits + fast git state - // (worktree, HEAD, branch identity) before the slow fetch completes. - // This lets the UI show commits and worktree state immediately. - let unlistenPartial: UnlistenFn | null = null; + // Listen for git-state-updated events emitted by refresh_branch_git_state. + // Merges the fresh git state into the existing timeline without a full reload. + let unlistenGitState: UnlistenFn | null = null; $effect(() => { const branchId = branch.id; - listen<{ branchId: string; commits: CommitTimelineItem[]; gitState: BranchGitState }>( - 'timeline-partial', - (event) => { - if (event.payload.branchId !== branchId) return; - const { commits: partialCommits, gitState: partialGitState } = event.payload; - - if (!timeline) { - // No existing timeline — create a minimal one from the partial data - timeline = { - commits: partialCommits, - notes: [], - reviews: [], - images: [], - gitState: partialGitState, - }; - loading = false; - } else { - // Merge: update commits and fast git state fields, preserve - // existing upstream/base data so those rows don't flash away - timeline = { - ...timeline, - commits: partialCommits, - gitState: timeline.gitState - ? { - ...timeline.gitState, - headSha: partialGitState.headSha, - currentBranch: partialGitState.currentBranch, - detachedHead: partialGitState.detachedHead, - expectedBranchMatches: partialGitState.expectedBranchMatches, - worktree: partialGitState.worktree, - } - : partialGitState, - }; - } + listen<{ branchId: string; gitState: BranchGitState }>('git-state-updated', (event) => { + if (event.payload.branchId !== branchId) return; + if (timeline) { + timeline = { ...timeline, gitState: event.payload.gitState }; } - ).then((unlisten) => { - unlistenPartial = unlisten; + refreshingGitState = false; + }).then((unlisten) => { + unlistenGitState = unlisten; }); return () => { - unlistenPartial?.(); + unlistenGitState?.(); }; }); @@ -753,8 +719,7 @@ async function loadTimeline({ timelineKey = branchTimelineReadyKey(branch), force = false, - skipFetch = false, - }: { timelineKey?: string | null; force?: boolean; skipFetch?: boolean } = {}) { + }: { timelineKey?: string | null; force?: boolean } = {}) { if (!timelineKey) return; const loadVersion = ++timelineLoadVersion; @@ -763,7 +728,6 @@ error = null; // Cancel any in-flight revalidation so it can't overwrite fresher data revalidationVersion++; - refreshingGitState = true; try { if (isInitialLoad) { @@ -782,7 +746,6 @@ const nextTimeline = await commands.getBranchTimeline(branch.id, { force: force || !isInitialLoad, - skipFetch, }); if (!isCurrentTimelineLoad(loadVersion, timelineKey)) return; timeline = nextTimeline; @@ -798,9 +761,15 @@ } if (isCurrentTimelineLoad(loadVersion, timelineKey)) { loading = false; - refreshingGitState = false; } } + + // 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; + }); } function getTimelineReviewDetails(fullReview: TimelineFullReview): TimelineReviewDetails { diff --git a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts index 88e4f1ced..96f86af5f 100644 --- a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts +++ b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts @@ -38,7 +38,7 @@ export default class BranchCardSessionManager { // Private callback refs — declared first so $derived fields can reference them private getBranch: () => Branch = undefined!; private getIsRemote: () => boolean = undefined!; - private loadTimeline: (opts?: { skipFetch?: boolean }) => void = undefined!; + private loadTimeline: () => void = undefined!; private getTimeline: () => BranchTimelineData | null = () => null; private setTimeline: (tl: BranchTimelineData) => void = undefined!; @@ -99,7 +99,7 @@ export default class BranchCardSessionManager { constructor(opts: { getBranch: () => Branch; getIsRemote: () => boolean; - loadTimeline: (opts?: { skipFetch?: boolean }) => void; + loadTimeline: () => void; getTimeline: () => BranchTimelineData | null; setTimeline: (tl: BranchTimelineData) => void; }) { @@ -264,7 +264,7 @@ export default class BranchCardSessionManager { this.autoReviewSessionId = null; this.autoReviewId = null; - this.loadTimeline({ skipFetch: true }); + this.loadTimeline(); return true; } catch (e) { console.error('[BranchCard] Failed to adopt auto review:', e); From 955a7314796fb44e675323d6a2e499bcedca7549 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 11 May 2026 13:33:50 +1000 Subject: [PATCH 3/3] fix: add loadTimeline opts type to BranchCardSessionManager The loadTimeline callback accepts optional parameters but was typed as () => void, causing implicit-any and type-assignment errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/features/branches/BranchCardSessionManager.svelte.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts index 96f86af5f..c25a9a409 100644 --- a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts +++ b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts @@ -38,7 +38,8 @@ export default class BranchCardSessionManager { // Private callback refs — declared first so $derived fields can reference them private getBranch: () => Branch = undefined!; private getIsRemote: () => boolean = undefined!; - private loadTimeline: () => void = undefined!; + private loadTimeline: (opts?: { timelineKey?: string | null; force?: boolean }) => void = + undefined!; private getTimeline: () => BranchTimelineData | null = () => null; private setTimeline: (tl: BranchTimelineData) => void = undefined!; @@ -99,7 +100,7 @@ export default class BranchCardSessionManager { constructor(opts: { getBranch: () => Branch; getIsRemote: () => boolean; - loadTimeline: () => void; + loadTimeline: (opts?: { timelineKey?: string | null; force?: boolean }) => void; getTimeline: () => BranchTimelineData | null; setTimeline: (tl: BranchTimelineData) => void; }) {