diff --git a/crates/diffcore-core/src/config.rs b/crates/diffcore-core/src/config.rs index b529bea..36e4098 100644 --- a/crates/diffcore-core/src/config.rs +++ b/crates/diffcore-core/src/config.rs @@ -665,6 +665,14 @@ impl DiffcoreConfig { None } + /// Fold the global LLM config into this (repo-local) one. + /// + /// Optional settings take the local value when it is set and fall back to + /// the global one. The enable flags are plain bools defaulting to `true`, + /// so "the repo never mentioned this" and "the repo asked for it" are the + /// same value — an OR would make a global opt-out unreachable for the many + /// repos with no `.diffcore.toml` at all. These passes cost the user money, + /// so either side may switch one off and neither can force it back on. fn apply_global_llm_defaults(&mut self, global: &Self) { self.llm.provider = self .llm @@ -678,8 +686,10 @@ impl DiffcoreConfig { .clone() .or_else(|| global.llm.key_cmd.clone()); self.llm.key = self.llm.key.clone().or_else(|| global.llm.key.clone()); + self.llm.annotations_enabled = + self.llm.annotations_enabled && global.llm.annotations_enabled; - self.llm.refinement.enabled = self.llm.refinement.enabled || global.llm.refinement.enabled; + self.llm.refinement.enabled = self.llm.refinement.enabled && global.llm.refinement.enabled; self.llm.refinement.provider = self .llm .refinement @@ -699,7 +709,7 @@ impl DiffcoreConfig { .clone() .or_else(|| global.llm.refinement.key_cmd.clone()); - self.llm.metadata.enabled = self.llm.metadata.enabled || global.llm.metadata.enabled; + self.llm.metadata.enabled = self.llm.metadata.enabled && global.llm.metadata.enabled; self.llm.metadata.provider = self .llm .metadata @@ -903,6 +913,24 @@ paths = ["dist/**"] assert_eq!(merged.llm.refinement.provider, Some("claude".to_string())); } + #[test] + fn global_opt_out_survives_a_repo_that_never_mentions_the_flag() { + // The desktop app only ever writes the global config, so turning a pass + // off there must not be undone by a repo whose .diffcore.toml is silent + // on the subject. + let mut global = DiffcoreConfig::default(); + global.llm.annotations_enabled = false; + global.llm.refinement.enabled = false; + global.llm.metadata.enabled = false; + + let mut merged = DiffcoreConfig::default(); + merged.apply_global_llm_defaults(&global); + + assert!(!merged.llm.annotations_enabled, "annotations should stay off"); + assert!(!merged.llm.refinement.enabled, "refinement should stay off"); + assert!(!merged.llm.metadata.enabled, "metadata should stay off"); + } + #[test] fn test_save_global_roundtrip_via_custom_home() { let home = tempfile::tempdir().unwrap(); diff --git a/crates/diffcore-core/src/llm/refinement.rs b/crates/diffcore-core/src/llm/refinement.rs index a2f7a46..b4923d4 100644 --- a/crates/diffcore-core/src/llm/refinement.rs +++ b/crates/diffcore-core/src/llm/refinement.rs @@ -713,6 +713,130 @@ pub fn has_refinements(response: &RefinementResponse) -> bool { || !response.reclassifications.is_empty() } +/// Share of the fresh diff's files that may be new since the last refinement +/// before carrying that refinement over stops paying off. Past this, the +/// grouping has moved far enough that repairing it costs the LLM more context +/// than regrouping from scratch. +pub const INCREMENTAL_MAX_NEW_FILE_RATIO: f64 = 0.10; + +fn all_paths(analysis: &crate::types::AnalysisOutput) -> HashSet<&str> { + analysis + .groups + .iter() + .flat_map(|g| g.files.iter().map(|f| f.path.as_str())) + .chain( + analysis + .infrastructure_group + .iter() + .flat_map(|ig| ig.files.iter().map(String::as_str)), + ) + .collect() +} + +fn previous_paths<'a>( + prev_groups: &'a [FlowGroup], + prev_infra: Option<&'a InfrastructureGroup>, +) -> HashSet<&'a str> { + prev_groups + .iter() + .flat_map(|g| g.files.iter().map(|f| f.path.as_str())) + .chain( + prev_infra + .iter() + .flat_map(|ig| ig.files.iter().map(String::as_str)), + ) + .collect() +} + +/// Fraction of the fresh analysis's files that did not exist in the previous +/// refinement. Compare against [`INCREMENTAL_MAX_NEW_FILE_RATIO`] to decide +/// whether to carry that refinement over or start again. +pub fn new_file_ratio( + fresh: &crate::types::AnalysisOutput, + prev_groups: &[FlowGroup], + prev_infra: Option<&InfrastructureGroup>, +) -> f64 { + let fresh_paths = all_paths(fresh); + if fresh_paths.is_empty() { + return 0.0; + } + let prev_paths = previous_paths(prev_groups, prev_infra); + fresh_paths.difference(&prev_paths).count() as f64 / fresh_paths.len() as f64 +} + +/// Carry a previously refined grouping onto a fresh analysis for incremental +/// refinement. +/// +/// Rebuilds the fresh analysis's groups from the previous refinement's +/// name+files layout (via the manifest import path, which preserves fresh file +/// metadata and intra-group edges), dropping files that no longer exist and +/// routing files new since the previous refinement into the unassigned set — +/// where the refinement prompt already asks the LLM to place them. +/// +/// Returns the carried-over analysis and a delta summary for the prompt's +/// `diff_summary` slot. +// ponytail: delta is path add/remove only; add per-file change stats if +// refinement quality on modified-only pushes demands it. +pub fn carry_over_grouping( + fresh: &crate::types::AnalysisOutput, + prev_groups: &[FlowGroup], + prev_infra: Option<&InfrastructureGroup>, +) -> (crate::types::AnalysisOutput, String) { + let fresh_paths = all_paths(fresh); + let fresh_grouped: HashSet<&str> = fresh + .groups + .iter() + .flat_map(|g| g.files.iter().map(|f| f.path.as_str())) + .collect(); + let prev_paths = previous_paths(prev_groups, prev_infra); + + let added = fresh_paths.difference(&prev_paths).count(); + let removed = prev_paths.difference(&fresh_paths).count(); + + let groups: Vec = prev_groups + .iter() + .map(|g| crate::manifest::ManifestGroup { + name: g.name.clone(), + files: g + .files + .iter() + .map(|f| f.path.clone()) + .filter(|p| fresh_grouped.contains(p.as_str())) + .collect(), + review_order: g.review_order, + description: None, + }) + .filter(|mg| !mg.files.is_empty()) + .collect(); + + let assigned: HashSet<&str> = groups + .iter() + .flat_map(|mg| mg.files.iter().map(String::as_str)) + .collect(); + let mut unassigned_files: Vec = fresh_paths + .difference(&assigned) + .map(|p| p.to_string()) + .collect(); + unassigned_files.sort(); + + let manifest = crate::manifest::GroupsManifest { + version: crate::manifest::GroupsManifest::VERSION.to_string(), + groups, + unassigned_files, + }; + let carried = crate::manifest::import_manifest(fresh, &manifest); + + let delta_summary = format!( + "Incremental update to a previously AI-refined grouping. Since the last \ + refinement: {} files added (listed under Ungrouped), {} files removed. \ + The groups below already reflect the previous refinement — only adjust \ + where the new files or removals warrant; otherwise return empty arrays.", + added, removed, + ); + + (carried, delta_summary) +} + // ── Internal helpers ── fn remove_file_from_group_or_infra( @@ -2476,4 +2600,183 @@ mod tests { assert!(prompt.contains("infrastructure]")); assert!(prompt.contains("NEVER substitute")); } + + // ── carry_over_grouping tests ── + + fn make_carry_analysis( + groups: Vec, + infra_files: Vec<&str>, + ) -> crate::types::AnalysisOutput { + crate::types::AnalysisOutput { + version: "1.0.0".to_string(), + diff_source: crate::types::DiffSource { + diff_type: crate::types::DiffType::BranchComparison, + base: None, + head: None, + base_sha: None, + head_sha: Some("head2".to_string()), + }, + summary: crate::types::AnalysisSummary { + total_files_changed: 0, + total_groups: groups.len() as u32, + languages_detected: vec![], + frameworks_detected: vec![], + }, + groups, + infrastructure_group: if infra_files.is_empty() { + None + } else { + Some(InfrastructureGroup { + files: infra_files.iter().map(|s| s.to_string()).collect(), + sub_groups: vec![], + reason: "test".to_string(), + }) + }, + annotations: None, + } + } + + #[test] + fn new_file_ratio_gates_incremental_on_how_much_the_diff_moved() { + let prev = vec![make_group( + "group_refined_1", + "auth", + (0..20) + .map(|i| make_file(&format!("f{i}.ts"), i)) + .collect(), + )]; + let unchanged = make_carry_analysis( + vec![make_group( + "group_1", + "fresh", + (0..20) + .map(|i| make_file(&format!("f{i}.ts"), i)) + .collect(), + )], + vec![], + ); + assert_eq!(new_file_ratio(&unchanged, &prev, None), 0.0); + + // One new file in 21 is under the threshold: carry the refinement over. + let one_new = make_carry_analysis( + vec![make_group( + "group_1", + "fresh", + (0..21) + .map(|i| make_file(&format!("f{i}.ts"), i)) + .collect(), + )], + vec![], + ); + assert!(new_file_ratio(&one_new, &prev, None) <= INCREMENTAL_MAX_NEW_FILE_RATIO); + + // Five new files in 25 is over it: regroup from scratch. + let many_new = make_carry_analysis( + vec![make_group( + "group_1", + "fresh", + (0..25) + .map(|i| make_file(&format!("f{i}.ts"), i)) + .collect(), + )], + vec![], + ); + assert!(new_file_ratio(&many_new, &prev, None) > INCREMENTAL_MAX_NEW_FILE_RATIO); + } + + #[test] + fn carry_over_keeps_previous_groups_and_routes_new_files_to_unassigned() { + let fresh = make_carry_analysis( + vec![make_group( + "group_1", + "fresh grouping", + vec![ + make_file("a.ts", 0), + make_file("b.ts", 1), + make_file("d.ts", 2), + ], + )], + vec![], + ); + let prev = vec![ + make_group("group_refined_1", "auth", vec![make_file("a.ts", 0)]), + make_group("group_refined_2", "billing", vec![make_file("b.ts", 0)]), + ]; + + let (carried, delta) = carry_over_grouping(&fresh, &prev, None); + + let names: Vec<&str> = carried.groups.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, vec!["auth", "billing"]); + let infra = carried.infrastructure_group.expect("new file goes unassigned"); + assert_eq!(infra.files, vec!["d.ts"]); + assert!(delta.contains("1 files added")); + assert!(delta.contains("0 files removed")); + } + + #[test] + fn carry_over_drops_removed_files_and_prunes_empty_groups() { + let fresh = make_carry_analysis( + vec![make_group("group_1", "g", vec![make_file("a.ts", 0)])], + vec![], + ); + let prev = vec![ + make_group( + "r1", + "keep", + vec![make_file("a.ts", 0), make_file("gone.ts", 1)], + ), + make_group("r2", "all gone", vec![make_file("gone2.ts", 0)]), + ]; + + let (carried, delta) = carry_over_grouping(&fresh, &prev, None); + + assert_eq!(carried.groups.len(), 1); + assert_eq!(carried.groups[0].name, "keep"); + let paths: Vec<&str> = carried.groups[0].files.iter().map(|f| f.path.as_str()).collect(); + assert_eq!(paths, vec!["a.ts"]); + assert!(carried.infrastructure_group.is_none()); + assert!(delta.contains("2 files removed")); + } + + #[test] + fn carry_over_preserves_review_order_and_previous_infra_stays_unassigned() { + let fresh = make_carry_analysis( + vec![make_group( + "group_1", + "g", + vec![make_file("a.ts", 0), make_file("b.ts", 1)], + )], + vec!["infra.toml"], + ); + let prev_groups = vec![ + FlowGroup { + review_order: 2, + ..make_group("r1", "second", vec![make_file("a.ts", 0)]) + }, + FlowGroup { + review_order: 1, + ..make_group("r2", "first", vec![make_file("b.ts", 0)]) + }, + ]; + let prev_infra = InfrastructureGroup { + files: vec!["infra.toml".to_string()], + sub_groups: vec![], + reason: "prev".to_string(), + }; + + let (carried, delta) = + carry_over_grouping(&fresh, &prev_groups, Some(&prev_infra)); + + let orders: Vec<(String, u32)> = carried + .groups + .iter() + .map(|g| (g.name.clone(), g.review_order)) + .collect(); + assert!(orders.contains(&("second".to_string(), 2))); + assert!(orders.contains(&("first".to_string(), 1))); + let infra = carried.infrastructure_group.expect("infra file stays unassigned"); + assert_eq!(infra.files, vec!["infra.toml"]); + assert!(delta.contains("0 files added")); + assert!(delta.contains("0 files removed")); + } } diff --git a/crates/diffcore-core/src/pr_url.rs b/crates/diffcore-core/src/pr_url.rs index b8e02d9..eee9b96 100644 --- a/crates/diffcore-core/src/pr_url.rs +++ b/crates/diffcore-core/src/pr_url.rs @@ -533,6 +533,31 @@ pub fn resolve(pr: &PrUrl) -> Result { resolve_in(pr, &root) } +/// SHA of the PR's tip on the remote, via a single `ls-remote` — no clone, no +/// checkout. Providers that publish only a merge ref (Azure DevOps) report that +/// ref's SHA instead; it moves whenever either side of the PR does, which is +/// all a change watcher needs. +pub fn remote_head_sha(pr: &PrUrl) -> Result { + let glob = pr + .ref_glob() + .ok_or(PrUrlError::NoGitRefs(pr.provider.name()))?; + let listing = git(Path::new("."), &["ls-remote", &pr.clone_url, &glob])?; + let pairs: Vec<(&str, &str)> = listing + .lines() + .filter_map(|l| l.split_once('\t').map(|(sha, r)| (sha.trim(), r.trim()))) + .collect(); + let refs: Vec = pairs.iter().map(|(_, r)| r.to_string()).collect(); + let not_found = + || PrUrlError::NotFound(pr.provider.unit(), pr.number, pr.clone_url.clone()); + let (head_ref, merge_ref) = pr.pick_refs(&refs).ok_or_else(not_found)?; + let want = head_ref.or(merge_ref).ok_or_else(not_found)?; + pairs + .iter() + .find(|(_, r)| *r == want) + .map(|(sha, _)| sha.to_string()) + .ok_or_else(not_found) +} + /// `resolve`, against an explicit cache root. Tests use this so they never have /// to mutate the process environment, which races every other thread's getenv. pub(crate) fn resolve_in(pr: &PrUrl, root: &Path) -> Result { @@ -910,6 +935,24 @@ mod tests { } } + #[test] + fn remote_head_sha_tracks_the_pr_tip_without_cloning() { + with_cache(|root, _cache| { + let o = Origin::new(root, "origin"); + let tip = o.open_pr(1, "feature.txt"); + let pr = local_pr(&o, 1); + assert_eq!(remote_head_sha(&pr).unwrap(), tip); + + // New commits land on the PR branch and the provider republishes + // the head ref — the watcher must see the new SHA. + o.run(&["checkout", "-q", "pr1"]); + let new_tip = o.commit("feature.txt", "more work\n", "more work"); + o.run(&["checkout", "-q", "main"]); + o.run(&["update-ref", "refs/pull/1/head", &new_tip]); + assert_eq!(remote_head_sha(&pr).unwrap(), new_tip); + }); + } + #[test] fn resolve_uses_the_merge_ref_parent_as_the_target_tip() { with_cache(|root, cache| { diff --git a/crates/diffcore-core/tests/adversarial_refinement.rs b/crates/diffcore-core/tests/adversarial_refinement.rs index 31f32fd..50add81 100644 --- a/crates/diffcore-core/tests/adversarial_refinement.rs +++ b/crates/diffcore-core/tests/adversarial_refinement.rs @@ -1187,3 +1187,105 @@ fn adversarial_fixtures_deterministic_consistency() { ); } } + +/// Incremental refinement: refine, land a new commit, carry the refined +/// grouping onto the fresh analysis, refine again with the delta summary. +/// The incremental result must not score materially worse than refining +/// the updated diff from scratch would have started from. +#[test] +fn incremental_refinement_carries_previous_grouping() { + if !should_run_live() { + eprintln!("Skipping incremental refinement test (set DIFFCORE_RUN_LIVE_LLM_TESTS=1)"); + return; + } + + use diffcore_core::llm::refinement::carry_over_grouping; + + let provider = create_vcr_provider(); + let (name, builder) = ADVERSARIAL_FIXTURES[0]; + let (rb, baseline) = builder(); + let actual_branch = { + let repo = git2::Repository::open(rb.path()).unwrap(); + let branches = repo.branches(Some(git2::BranchType::Local)).unwrap(); + let mut found = None; + for branch in branches { + let (b, _) = branch.unwrap(); + let bname = b.name().unwrap().unwrap().to_string(); + if bname != "main" { + found = Some(bname); + break; + } + } + found.expect("Should have a feature branch") + }; + + // 1. Deterministic analysis + from-scratch refinement. + let det = run_full_pipeline(&rb, "main", &actual_branch); + let (scratch, _) = apply_refinement_to_output(&det, provider.as_ref(), &vcr_cache_dir()); + let scratch_scores = score_output(&scratch, &baseline); + eprintln!(" {}: from-scratch refined overall={:.4}", name, scratch_scores.overall); + + // 2. A new commit lands on the feature branch after the refinement. + rb.checkout(&actual_branch); + rb.write_file("src/modules/late_addition.ts", "export const late = 1;\n"); + rb.commit("late addition"); + + // 3. Fresh deterministic analysis, previous refinement carried onto it. + let fresh = run_full_pipeline(&rb, "main", &actual_branch); + let (carried, delta_summary) = + carry_over_grouping(&fresh, &scratch.groups, scratch.infrastructure_group.as_ref()); + assert!( + carried + .infrastructure_group + .as_ref() + .is_some_and(|ig| ig.files.iter().any(|f| f.contains("late_addition"))), + "new file must land in the unassigned set for the LLM to place" + ); + assert!(delta_summary.contains("Incremental update")); + + // 4. Incremental refine: same op machinery, delta summary in diff_summary. + let analysis_json = serde_json::to_string_pretty(&carried).unwrap(); + let request = build_refinement_request( + &carried.groups, + carried.infrastructure_group.as_ref(), + &analysis_json, + &delta_summary, + ); + let rt = tokio::runtime::Runtime::new().unwrap(); + let incremental = match rt.block_on(provider.refine_groups(&request)) { + Ok(ref r) if has_refinements(r) => { + let (groups, infra, warnings) = apply_refinement_lenient( + &carried.groups, + carried.infrastructure_group.as_ref(), + r, + ); + for w in &warnings { + eprintln!(" Refinement repair: {}", w.message); + } + let mut out = carried.clone(); + out.groups = groups; + out.infrastructure_group = infra; + out.summary.total_groups = out.groups.len() as u32; + out + } + Ok(_) => carried.clone(), + Err(e) => { + eprintln!(" LLM incremental refinement call failed: {}", e); + carried.clone() + } + }; + + let inc_scores = score_output(&incremental, &baseline); + eprintln!( + " {}: incremental refined overall={:.4} (delta {:+.4})", + name, + inc_scores.overall, + inc_scores.overall - scratch_scores.overall + ); + assert!( + inc_scores.overall >= scratch_scores.overall - 0.20, + "incremental refinement degraded too far: incremental={:.4}, from-scratch={:.4}", + inc_scores.overall, + scratch_scores.overall + ); +} diff --git a/crates/diffcore-tauri/src/commands.rs b/crates/diffcore-tauri/src/commands.rs index 72f84cc..205b32a 100644 --- a/crates/diffcore-tauri/src/commands.rs +++ b/crates/diffcore-tauri/src/commands.rs @@ -56,6 +56,10 @@ pub struct AppState { pub watched_manifest_path: Mutex>, /// In-flight refinement tasks keyed by job_id, so the user can cancel them. pub refinement_jobs: Arc>>>, + /// The most recent refinement result, used as the baseline for incremental + /// refinement. `Arc` for the same reason as `last_analysis`: the streaming + /// job persists it from a spawned `'static` task. + pub last_refinement: Arc>>, /// Generation counter for the git HEAD watcher: each `watch_git_head` call bumps this, /// and the polling thread exits once it sees a value that no longer matches its own, /// so switching repos or unwatching cleanly stops the previous thread. @@ -80,6 +84,7 @@ impl AppState { last_file_centrality: Mutex::new(None), watched_manifest_path: Mutex::new(None), refinement_jobs: Arc::new(Mutex::new(HashMap::new())), + last_refinement: Arc::new(Mutex::new(None)), git_head_watch_generation: Arc::new(AtomicU64::new(0)), } } @@ -193,6 +198,12 @@ pub fn analyze( let repo_path = std::fs::canonicalize(&repo_path) .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; + // The in-memory refinement baseline belongs to the previous analysis; + // get_cached_refinement re-seeds it from disk for this one. + if let Ok(mut last) = state.last_refinement.lock() { + *last = None; + } + let repo = git2::Repository::discover(&repo_path) .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; @@ -807,6 +818,11 @@ async fn run_refinement_with_activity( job: JobHandle, file_centrality: Option>, weights: diffcore_core::types::RankWeights, + diff_summary: String, + // Incremental refinement carries the previous grouping onto the fresh + // analysis before the LLM sees it, so the result differs from the + // deterministic groups even when the LLM proposes no further ops. + baseline_changed: bool, ) -> Result { emit_diffcore_activity(&job, "Preparing refinement request").await; let provider = llm::create_provider_for_workdir(&refinement_llm_config, workdir.as_deref()) @@ -822,10 +838,6 @@ async fn run_refinement_with_activity( let analysis_json = serde_json::to_string_pretty(&analysis) .map_err(|e| CommandError::Llm(format!("Failed to serialize analysis: {}", e)))?; - let diff_summary = format!( - "{} files changed across {} groups", - analysis.summary.total_files_changed, analysis.summary.total_groups, - ); let request = refinement::build_refinement_request( &analysis.groups, analysis.infrastructure_group.as_ref(), @@ -863,12 +875,14 @@ async fn run_refinement_with_activity( if !refinement::has_refinements(&response) { emit_diffcore_activity(&job, "Refinement kept the current grouping").await; return Ok(RefinementResult { + head_sha: analysis.diff_source.head_sha.clone(), + files: refinement_files(&analysis.groups, analysis.infrastructure_group.as_ref()), refined_groups: analysis.groups.clone(), infrastructure_group: analysis.infrastructure_group.clone(), refinement_response: response, provider: provider_name, model: model_name, - had_changes: false, + had_changes: baseline_changed, warnings: Vec::new(), }); } @@ -901,6 +915,8 @@ async fn run_refinement_with_activity( .await; Ok(RefinementResult { + head_sha: analysis.diff_source.head_sha.clone(), + files: refinement_files(&refined_groups, infra.as_ref()), refined_groups, infrastructure_group: infra, refinement_response: response, @@ -1022,9 +1038,15 @@ pub fn start_refine_groups( repo_path: Option, llm_provider: Option, llm_model: Option, + incremental: Option, state: State<'_, AppState>, ) -> Result { - let analysis = load_cached_analysis(&state)?; + let (analysis, diff_summary, baseline_changed) = refinement_baseline( + &state, + load_cached_analysis(&state)?, + repo_path.as_deref(), + incremental.unwrap_or(false), + ); let (mut config, workdir) = load_config_from_path(repo_path.as_deref()); if let Some(provider) = llm_provider { config.llm.refinement.provider = Some(provider.clone()); @@ -1079,6 +1101,7 @@ pub fn start_refine_groups( let job_id_for_cleanup = job_id.clone(); let jobs_for_cleanup = Arc::clone(&state.refinement_jobs); let analysis_for_persist = Arc::clone(&state.last_analysis); + let refinement_for_persist = Arc::clone(&state.last_refinement); let file_centrality = state .last_file_centrality .lock() @@ -1093,6 +1116,8 @@ pub fn start_refine_groups( job.clone(), file_centrality, weights, + diff_summary, + baseline_changed, ) .await { @@ -1100,20 +1125,32 @@ pub fn start_refine_groups( // Persist before completing the job. Every backend command that // reads `last_analysis` — describe_groups, annotate_overview — // would otherwise keep answering about pre-refinement groups. - if response.had_changes { - match analysis_for_persist.lock() { - Ok(mut last) => { - if let Some(ref mut a) = *last { - a.groups = response.refined_groups.clone(); - a.infrastructure_group = response.infrastructure_group.clone(); - a.summary.total_groups = a.groups.len() as u32; + // Skip when a fresh analyze replaced the analysis this job + // started from; its groups would name files of the old diff. + match analysis_for_persist.lock() { + Ok(mut last) => { + let same_analysis = last + .as_ref() + .is_some_and(|a| a.diff_source.head_sha == response.head_sha); + if !same_analysis { + warn!("Analysis changed while refinement ran; discarding stale refinement result"); + } else { + if response.had_changes { + if let Some(a) = last.as_mut() { + a.groups = response.refined_groups.clone(); + a.infrastructure_group = response.infrastructure_group.clone(); + a.summary.total_groups = a.groups.len() as u32; + } + } + if let Ok(mut prev) = refinement_for_persist.lock() { + *prev = Some(response.clone()); } } - Err(error) => warn!( - "Failed to persist refined groups (lock poisoned): {}", - error - ), } + Err(error) => warn!( + "Failed to persist refined groups (lock poisoned): {}", + error + ), } match serde_json::to_value(&response) { @@ -1472,18 +1509,15 @@ pub async fn refine_groups( repo_path: Option, llm_provider: Option, llm_model: Option, + incremental: Option, state: State<'_, AppState>, ) -> Result { - // Get the cached analysis - let analysis = { - let last = state - .last_analysis - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; - last.clone().ok_or_else(|| { - CommandError::Analysis("No analysis available. Run analyze first.".into()) - })? - }; + let (analysis, diff_summary, baseline_changed) = refinement_baseline( + &state, + load_cached_analysis(&state)?, + repo_path.as_deref(), + incremental.unwrap_or(false), + ); // Load config, applying frontend overrides let (mut config, workdir) = load_config_from_path(repo_path.as_deref()); @@ -1534,11 +1568,6 @@ pub async fn refine_groups( let analysis_json = serde_json::to_string_pretty(&analysis) .map_err(|e| CommandError::Llm(format!("Failed to serialize analysis: {}", e)))?; - let diff_summary = format!( - "{} files changed across {} groups", - analysis.summary.total_files_changed, analysis.summary.total_groups, - ); - let request = refinement::build_refinement_request( &analysis.groups, analysis.infrastructure_group.as_ref(), @@ -1559,15 +1588,21 @@ pub async fn refine_groups( .unwrap_or_else(|| default_model_for_provider(&provider_name).to_string()); if !refinement::has_refinements(&response) { - return Ok(RefinementResult { + let result = RefinementResult { + head_sha: analysis.diff_source.head_sha.clone(), + files: refinement_files(&analysis.groups, analysis.infrastructure_group.as_ref()), refined_groups: analysis.groups.clone(), infrastructure_group: analysis.infrastructure_group.clone(), refinement_response: response, provider: provider_name, model: model_name, - had_changes: false, + had_changes: baseline_changed, warnings: Vec::new(), - }); + }; + if let Ok(mut last) = state.last_refinement.lock() { + *last = Some(result.clone()); + } + return Ok(result); } // Apply the refinement leniently: repair what we can, drop what we can't, @@ -1607,7 +1642,9 @@ pub async fn refine_groups( ), } - Ok(RefinementResult { + let result = RefinementResult { + head_sha: analysis.diff_source.head_sha.clone(), + files: refinement_files(&refined_groups, infra.as_ref()), refined_groups, infrastructure_group: infra, refinement_response: response, @@ -1615,7 +1652,11 @@ pub async fn refine_groups( model: model_name, had_changes: true, warnings, - }) + }; + if let Ok(mut last) = state.last_refinement.lock() { + *last = Some(result.clone()); + } + Ok(result) } /// Result of a refinement pass, including both the refined groups and @@ -1638,6 +1679,26 @@ pub struct RefinementResult { /// dropped operations. Empty in the common case. #[serde(default)] pub warnings: Vec, + /// Head commit the refined diff was computed against. `None` on cache + /// entries written before this field existed. + #[serde(default)] + pub head_sha: Option, + /// Every file path covered by the refinement (grouped + infrastructure), + /// so the file set at refinement time survives alongside the result. + #[serde(default)] + pub files: Vec, +} + +/// All file paths a refinement result covers: grouped files plus infrastructure. +fn refinement_files( + groups: &[diffcore_core::types::FlowGroup], + infra: Option<&diffcore_core::types::InfrastructureGroup>, +) -> Vec { + groups + .iter() + .flat_map(|g| g.files.iter().map(|f| f.path.clone())) + .chain(infra.iter().flat_map(|ig| ig.files.iter().cloned())) + .collect() } /// Load cached refinement result for the current analysis. @@ -1649,29 +1710,98 @@ pub fn get_cached_refinement( repo_path: Option, state: State<'_, AppState>, ) -> Result, CommandError> { - // Try diff-hash key first (exact content match) + let result = lookup_cached_refinement(&state, repo_path.as_deref()); + // A disk hit is the refinement baseline for this session — remember it so + // incremental refinement can build on it even after a fresh analyze. + if let Some(ref r) = result { + if let Ok(mut last) = state.last_refinement.lock() { + *last = Some(r.clone()); + } + } + Ok(result) +} + +/// Disk-cache lookup: diff-hash key first (exact content match), then +/// branch-based key (same branch across worktrees, even with different +/// uncommitted changes). +fn lookup_cached_refinement(state: &AppState, repo_path: Option<&str>) -> Option { let diff_key = state.last_cache_key.lock().ok().and_then(|k| k.clone()); if let Some(ref key) = diff_key { if let Some(json) = cache::load_cached_refinement(key) { if let Ok(result) = serde_json::from_str::(&json) { - return Ok(Some(result)); + return Some(result); } } } - // Fallback: try branch-based key (works across worktrees on same branch) - if let Some(ref repo) = repo_path { + if let Some(repo) = repo_path { if let Ok(branch_key) = comment_cache_key(repo) { let branch_refine_key = format!("branch_{}", branch_key); if let Some(json) = cache::load_cached_refinement(&branch_refine_key) { if let Ok(result) = serde_json::from_str::(&json) { - return Ok(Some(result)); + return Some(result); } } } } - Ok(None) + None +} + +/// The baseline for incremental refinement: this session's last refinement, +/// falling back to the disk cache (whose branch key survives new commits). +fn load_previous_refinement(state: &AppState, repo_path: Option<&str>) -> Option { + state + .last_refinement + .lock() + .ok() + .and_then(|last| last.clone()) + .or_else(|| lookup_cached_refinement(state, repo_path)) +} + +/// Baseline for a refinement pass: the cached analysis as-is, or in +/// incremental mode the previous refined grouping carried onto it so the LLM +/// is asked only for adjustments. With no previous refinement to build on, or +/// with too much of the diff new since it, incremental falls through to a +/// from-scratch refine — past that point regrouping is cheaper than repair. +/// ponytail: without incremental, the baseline is whatever last_analysis +/// holds — after an earlier refinement that's the refined groups, not the +/// deterministic ones. Keep a pristine copy in AppState if "from scratch" +/// must mean the deterministic grouping without a fresh analyze. +fn refinement_baseline( + state: &AppState, + analysis: AnalysisOutput, + repo_path: Option<&str>, + incremental: bool, +) -> (AnalysisOutput, String, bool) { + let summary = format!( + "{} files changed across {} groups", + analysis.summary.total_files_changed, analysis.summary.total_groups, + ); + if !incremental { + return (analysis, summary, false); + } + let Some(prev) = load_previous_refinement(state, repo_path) else { + return (analysis, summary, false); + }; + let new_ratio = refinement::new_file_ratio( + &analysis, + &prev.refined_groups, + prev.infrastructure_group.as_ref(), + ); + if new_ratio > refinement::INCREMENTAL_MAX_NEW_FILE_RATIO { + log::info!( + "Refinement: {:.0}% of files are new since the last refinement; regrouping from scratch", + new_ratio * 100.0, + ); + return (analysis, summary, false); + } + let (carried, delta) = refinement::carry_over_grouping( + &analysis, + &prev.refined_groups, + prev.infrastructure_group.as_ref(), + ); + (carried, delta, true) } /// Store a refinement result in the global cache ($XDG_CACHE_HOME/diffcore/refinements/). @@ -3067,6 +3197,52 @@ pub fn unwatch_git_head(state: State<'_, AppState>) -> Result<(), CommandError> Ok(()) } +/// Start watching a PR/MR URL's remote tip for new commits. Polls `git ls-remote` +/// every 60 seconds and emits a "pr-head-changed" event with the URL when the +/// tip SHA moves. Shares the generation counter with `watch_git_head`, so only +/// one repo watcher runs at a time and `unwatch_git_head` stops both kinds. +#[cfg(feature = "desktop")] +#[cfg_attr(feature = "desktop", tauri::command)] +pub fn watch_pr_head( + url: String, + app_handle: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), CommandError> { + let pr = pr_url::parse(&url) + .ok_or_else(|| CommandError::Git(format!("Not a recognized PR/MR URL: {}", url)))?; + let generation = state.git_head_watch_generation.fetch_add(1, Ordering::SeqCst) + 1; + let generation_counter = state.git_head_watch_generation.clone(); + + std::thread::spawn(move || { + // Seed with the current remote tip so only future pushes fire the event. + let mut last = pr_url::remote_head_sha(&pr).ok(); + + 'outer: loop { + // Sleep in 1s slices so unwatching stops the thread promptly + // instead of after a full poll interval. + for _ in 0..60 { + std::thread::sleep(std::time::Duration::from_secs(1)); + if generation_counter.load(Ordering::SeqCst) != generation { + break 'outer; + } + } + + match pr_url::remote_head_sha(&pr) { + Ok(sha) => { + if last.as_deref().is_some_and(|prev| prev != sha) { + let _ = app_handle.emit("pr-head-changed", &url); + } + last = Some(sha); + } + // Transient network/auth failures: skip this poll, never surface. + Err(e) => tracing::debug!(target: "activity", "PR watch poll failed: {}", e), + } + } + }); + + Ok(()) +} + fn detect_language(path: &str) -> String { match path.rsplit('.').next() { Some("ts" | "tsx") => "typescript".to_string(), @@ -3100,6 +3276,25 @@ fn detect_language(path: &str) -> String { mod tests { use super::*; + /// Cache entries written before head_sha/files existed must still load. + #[test] + fn refinement_result_deserializes_without_new_fields() { + let json = r#"{ + "refined_groups": [], + "infrastructure_group": null, + "refinement_response": { + "splits": [], "merges": [], "re_ranks": [], + "reclassifications": [], "reasoning": "" + }, + "provider": "anthropic", + "model": "m", + "had_changes": false + }"#; + let result: RefinementResult = serde_json::from_str(json).unwrap(); + assert_eq!(result.head_sha, None); + assert!(result.files.is_empty()); + } + #[test] fn test_detect_language_typescript() { assert_eq!(detect_language("src/app.ts"), "typescript"); @@ -3424,6 +3619,8 @@ mod tests { model: "claude-sonnet-4-6".to_string(), had_changes: false, warnings: Vec::new(), + head_sha: None, + files: Vec::new(), }; let json = serde_json::to_string(&result).unwrap(); let back: RefinementResult = serde_json::from_str(&json).unwrap(); @@ -3479,6 +3676,8 @@ mod tests { model: "gpt-4.1".to_string(), had_changes: true, warnings: Vec::new(), + head_sha: Some("abc123".to_string()), + files: vec!["test.ts".to_string()], }; let json = serde_json::to_string(&result).unwrap(); let back: RefinementResult = serde_json::from_str(&json).unwrap(); diff --git a/crates/diffcore-tauri/src/main.rs b/crates/diffcore-tauri/src/main.rs index f8c7eed..299bb9c 100644 --- a/crates/diffcore-tauri/src/main.rs +++ b/crates/diffcore-tauri/src/main.rs @@ -103,6 +103,7 @@ fn main() { commands::watch_manifest, commands::unwatch_manifest, commands::watch_git_head, + commands::watch_pr_head, commands::unwatch_git_head, ]) .run(tauri::generate_context!()) diff --git a/crates/diffcore-tauri/src/web_server.rs b/crates/diffcore-tauri/src/web_server.rs index 120ccc6..6ca3314 100644 --- a/crates/diffcore-tauri/src/web_server.rs +++ b/crates/diffcore-tauri/src/web_server.rs @@ -203,7 +203,9 @@ async fn invoke( } "refine_groups" => { let (a, b, c) = llm_args(&mut args)?; - return ok(commands::refine_groups(a, b, c, State(&state.app)).await?).map(Json); + let incremental = opt(&mut args, "incremental")?; + return ok(commands::refine_groups(a, b, c, incremental, State(&state.app)).await?) + .map(Json); } "describe_groups" => { let (a, _, _) = llm_args(&mut args)?; @@ -318,7 +320,8 @@ fn dispatch_sync(cmd: &str, args: &mut Args, app: &AppState) -> Result { let (a, b, c) = llm_args(args)?; - ok(commands::start_refine_groups(a, b, c, state)?) + let incremental = opt(args, "incremental")?; + ok(commands::start_refine_groups(a, b, c, incremental, state)?) } "start_annotate_group" => { let group_id = req(args, "groupId")?; @@ -412,7 +415,7 @@ fn dispatch_sync(cmd: &str, args: &mut Args, app: &AppState) -> Result ok(commands::unwatch_manifest(state)?), "unwatch_git_head" => ok(commands::unwatch_git_head(state)?), - "watch_manifest" | "watch_git_head" => Err(unsupported( + "watch_manifest" | "watch_git_head" | "watch_pr_head" => Err(unsupported( "file watching is desktop-only; refresh manually in web mode", )), "open_in_editor" | "check_editors_available" => { diff --git a/crates/diffcore-tauri/ui/package.json b/crates/diffcore-tauri/ui/package.json index 98121e1..84cb77f 100644 --- a/crates/diffcore-tauri/ui/package.json +++ b/crates/diffcore-tauri/ui/package.json @@ -8,7 +8,8 @@ "build": "tsc && vite build", "preview": "vite preview", "test:e2e": "playwright test", - "test:e2e:headed": "playwright test --headed" + "test:e2e:headed": "playwright test --headed", + "typecheck:e2e": "tsc -p tsconfig.e2e.json" }, "dependencies": { "@dagrejs/dagre": "^2.0.4", diff --git a/crates/diffcore-tauri/ui/src/App.tsx b/crates/diffcore-tauri/ui/src/App.tsx index c421a26..cdc2f97 100644 --- a/crates/diffcore-tauri/ui/src/App.tsx +++ b/crates/diffcore-tauri/ui/src/App.tsx @@ -284,6 +284,12 @@ export default function App() { const [selectedFile, setSelectedFile] = useState(null); const [fileDiff, setFileDiff] = useState(null); const [loading, setLoading] = useState(false); + const [analyzeArmed, setAnalyzeArmed] = useState(false); + useEffect(() => { + if (!analyzeArmed) return; + const t = setTimeout(() => setAnalyzeArmed(false), 3000); + return () => clearTimeout(t); + }, [analyzeArmed]); const [error, setError] = useState(null); // Test-only: when set, the named panel's ErrorBoundary will catch a deliberate crash const [crashPanel, setCrashPanel] = useState(null); @@ -343,6 +349,9 @@ export default function App() { refsPinned: refsPinned.current, }); }, [repoPath, baseRef, headRef, analysis]); + /** The PR/MR URL behind the current checkout, so refresh can re-resolve it + * (which updates the cached checkout) and the watcher can poll the remote. */ + const prUrlRef = useRef(null); const [branchDropdownOpen, setBranchDropdownOpen] = useState(false); const [headBranchDropdownOpen, setHeadBranchDropdownOpen] = useState(false); @@ -469,6 +478,12 @@ export default function App() { const groupListTransitionTimers = useRef([]); const [refining, setRefining] = useState(false); const refinementJobIdRef = useRef(null); + // Armed by an explicit Analyze click when "Refine" is checked, so the LLM + // pass chains onto that one click. Auto-analysis on load never arms it and + // so never spends tokens. + const refineAfterAnalysisRef = useRef(false); + const activityAbortRef = useRef<(() => void) | null>(null); + const [newCommitsAvailable, setNewCommitsAvailable] = useState(false); // Context menu state (right-click on file items) const [contextMenu, setContextMenu] = useState<{ x: number; y: number; filePath: string } | null>(null); @@ -693,11 +708,19 @@ export default function App() { } }, [repoPath, loadRepoInfo]); - // Watch the repo's HEAD for changes made outside the app (git pull/checkout/merge in a - // terminal). The watcher lives in Rust and emits "git-head-changed"; see the listener below. + // Watch for new commits made outside the app. Local repos poll HEAD via git2; + // PR/MR checkouts poll the remote tip via ls-remote. Both watchers live in + // Rust ("git-head-changed" / "pr-head-changed"); see the listeners below. + // unwatch_git_head stops either kind. useEffect(() => { - if (!IS_TAURI || !repoPath || isPrUrl(repoPath) || prRefs.current) return; - tauriInvoke("watch_git_head", { repoPath }).catch(() => {}); + if (!IS_TAURI || !repoPath) return; + if (prRefs.current && prUrlRef.current) { + tauriInvoke("watch_pr_head", { url: prUrlRef.current }).catch(() => {}); + } else if (!isPrUrl(repoPath) && !prRefs.current) { + tauriInvoke("watch_git_head", { repoPath }).catch(() => {}); + } else { + return; + } return () => { tauriInvoke("unwatch_git_head", {}).catch(() => {}); }; @@ -785,6 +808,10 @@ export default function App() { source.close(); activitySources.current.delete(source); }; + activityAbortRef.current = () => { + close(); + reject(new Error("Cancelled by user")); + }; source.addEventListener("job_started", (event) => { try { @@ -1123,6 +1150,14 @@ export default function App() { lastBackendArgs.current.analyze = { repoPath: path, base: baseRef, head: headRef }; setLoading(true); setError(null); + // A refinement still in flight would otherwise never settle once its + // stream is closed below, leaving the top bar stuck on "Refining…". + if (refinementJobIdRef.current) { + if (HAS_BACKEND) { + await tauriInvoke("cancel_refine_groups", { jobId: refinementJobIdRef.current }).catch(() => {}); + } + activityAbortRef.current?.(); + } // Reset LLM state on new analysis setOverview(null); setDeepAnalyses({}); @@ -1141,6 +1176,7 @@ export default function App() { setRefinementHadChanges(null); setShowRefined(false); refinementApplied.current = false; + setNewCommitsAvailable(false); // Reset review tick-off state setReviewedGroupIds(new Set()); setDismissedEmptyGroupIds(new Set()); @@ -1183,10 +1219,30 @@ export default function App() { ); handleSelectGroup(sorted[0]); } - // Check for cached refinement and auto-apply if found + // Check for cached refinement. Auto-apply only when it was computed + // against this head commit (legacy entries without a SHA still apply) + // and it covers exactly the files in the fresh diff; a stale one is + // left for the backend to carry over on the next incremental refine. if (HAS_BACKEND) { tauriInvoke("get_cached_refinement", { repoPath: path || null }).then((cached) => { - if (cached) { + if (!cached) return; + const cachedSha = cached.head_sha ?? null; + const freshSha = result.diff_source.head_sha ?? null; + const freshFiles = new Set([ + ...result.groups.flatMap((g) => g.files.map((f) => f.path)), + ...(result.infrastructure_group?.files ?? []), + ]); + const cachedFiles = new Set( + cached.files?.length + ? cached.files + : [ + ...cached.refined_groups.flatMap((g) => g.files.map((f) => f.path)), + ...(cached.infrastructure_group?.files ?? []), + ], + ); + const sameFiles = + cachedFiles.size === freshFiles.size && [...cachedFiles].every((p) => freshFiles.has(p)); + if (sameFiles && (!cachedSha || !freshSha || cachedSha === freshSha)) { applyRefinementResult(cached, { fromCache: true }); } }).catch(() => {}); @@ -1227,6 +1283,7 @@ export default function App() { } prRefs.current = true; refsPinned.current = true; + prUrlRef.current = value; setRepoPath(resolved.path); setBaseRef(resolved.base); setHeadRef(resolved.head); @@ -1234,6 +1291,33 @@ export default function App() { setPendingAnalysis(true); }, [repoPath, loading, runAnalysis]); + /** Re-run analysis after new commits. For a PR/MR this re-resolves the URL, + * which updates the cached checkout to the new tip before analyzing. */ + const refreshAnalysis = useCallback(async () => { + setNewCommitsAvailable(false); + const url = prUrlRef.current; + if (url && HAS_BACKEND) { + setLoading(true); + setError(null); + try { + const resolved = await tauriInvoke("resolve_pr_url", { url }); + prRefs.current = true; + setRepoPath(resolved.path); + setBaseRef(resolved.base); + setHeadRef(resolved.head); + setPendingAnalysis(true); + } catch (e) { + setLoading(false); + setError(String(e)); + } + return; + } + if (repoPath && !isPrUrl(repoPath)) { + loadRepoInfo(repoPath); + } + runAnalysis(); + }, [repoPath, loadRepoInfo, runAnalysis]); + // Runs once the resolved repo path has committed, so runAnalysis and every // callback it triggers close over the checkout rather than the URL. useEffect(() => { @@ -1266,6 +1350,24 @@ export default function App() { resolvedRefinementProvider, ); const aiAccessReady = hasApiKey || !!recommendedSubscriptionProvider; + /** The top-bar "Refine" checkbox. Backed by the persisted refinement + * setting, so ticking it off is also how the user changes the default. */ + const refineOnAnalyze = llmSettings?.refinement_enabled ?? true; + + /** Every explicit "analyze now" action. Arms the refine chain from the + * checkbox; `refresh` re-resolves a PR/MR URL first so new upstream commits + * are picked up. */ + const startAnalysis = useCallback( + (opts?: { refresh?: boolean }) => { + refineAfterAnalysisRef.current = refineOnAnalyze; + if (opts?.refresh) { + refreshAnalysis(); + } else { + submitRepoInput(); + } + }, + [refineOnAnalyze, refreshAnalysis, submitRepoInput], + ); /** * Run Pass 1 and stash the PR-level overview without taking over the panel. @@ -1417,8 +1519,9 @@ export default function App() { } }, [handleSelectGroup, showToast, describeGroups, repoPath]); - /** Run LLM refinement pass on the current analysis groups. */ - const runRefinement = useCallback(async () => { + /** Run LLM refinement pass on the current analysis groups. Incremental mode + * builds on the previous refinement instead of refining from scratch. */ + const runRefinement = useCallback(async (opts?: { incremental?: boolean }) => { if (!analysis) return; setRefining(true); setError(null); @@ -1428,6 +1531,7 @@ export default function App() { repoPath: repoPath || null, llmProvider: resolvedRefinementProvider, llmModel: resolvedRefinementModel, + incremental: opts?.incremental ?? false, }, (result) => { applyRefinementResult(result); }, (jobId) => { @@ -1524,6 +1628,16 @@ export default function App() { restorePending.current = false; runAnalysis().then(reattachJobs); }, [llmSettings, runAnalysis, reattachJobs]); + // One click: an explicit Analyze with "Refine" checked runs the LLM pass as + // soon as the fresh groups land. Always incremental — the backend carries + // over the previous refinement when the diff has barely moved and falls + // back to a from-scratch pass when it has. + useEffect(() => { + if (!refineAfterAnalysisRef.current || loading) return; + refineAfterAnalysisRef.current = false; + if (!analysis || error || !aiAccessReady) return; + void runRefinement({ incremental: true }); + }, [analysis, loading, error, aiAccessReady, runRefinement]); /** Toggle between original and refined groups. */ const toggleRefinedView = useCallback( @@ -1611,6 +1725,7 @@ export default function App() { setActivityEntries(entries); }, setError: (msg: string | null) => setError(msg), + showNewCommits: (v: boolean) => setNewCommitsAvailable(v), clearAnalysis: () => { setAnalysis(null); setSelectedGroup(null); setSelectedFile(null); setFileDiff(null); setOverview(null); setDeepAnalyses({}); setOriginalGroups(null); setRefinedGroups(null); setRefinementResponse(null); setRefinementProvider(null); setRefinementModel(null); setRefinementHadChanges(null); setShowRefined(false); refinementApplied.current = false; setReviewedGroupIds(new Set()); setComments([]); setCommentInput(null); setCommentText(""); setRightPanelTab("annotations"); setSourceFocusRequest(null); setActivityJob(null); setActivityEntries([]); setActivityError(null); setActivityViewMode("stream"); setInspectedActivityId(null); }, openAiSetup: (step: OnboardingStep = "recommended") => openAiSetup(step), dismissAiSetup: () => dismissAiSetup(), @@ -2349,38 +2464,45 @@ export default function App() { useEffect(() => { loadingRef.current = loading; }, [loading]); const gitHeadDebounceRef = useRef(null); - // Listen for git-head-changed events and re-analyze so the left panel picks up - // commits pulled/merged/checked out outside the app. Debounced since operations like - // rebase can move HEAD several times in quick succession. + // Listen for new-commit events from the Rust watchers and surface a "New + // commits" bar instead of re-analyzing under the user mid-review — analysis + // only re-runs when they click Refresh. The local-HEAD event is debounced + // since operations like rebase move HEAD several times in quick succession. useEffect(() => { if (!IS_TAURI || !repoPath) return; let cancelled = false; - let unlisten: (() => void) | undefined; + const unlistens: Array<() => void> = []; (async () => { const { listen } = await import("@tauri-apps/api/event"); - const fn = await listen("git-head-changed", (event) => { + const onHead = await listen("git-head-changed", (event) => { if (cancelled || event.payload !== repoPath) return; if (gitHeadDebounceRef.current) window.clearTimeout(gitHeadDebounceRef.current); gitHeadDebounceRef.current = window.setTimeout(() => { if (loadingRef.current) return; loadRepoInfo(repoPath); - runAnalysis(); + setNewCommitsAvailable(true); }, 600); }); + const onPrHead = await listen("pr-head-changed", (event) => { + if (cancelled || event.payload !== prUrlRef.current) return; + if (loadingRef.current) return; + setNewCommitsAvailable(true); + }); if (cancelled) { - fn(); + onHead(); + onPrHead(); return; } - unlisten = fn; + unlistens.push(onHead, onPrHead); })(); return () => { cancelled = true; - unlisten?.(); + unlistens.forEach((fn) => fn()); if (gitHeadDebounceRef.current) window.clearTimeout(gitHeadDebounceRef.current); }; - }, [repoPath, loadRepoInfo, runAnalysis]); + }, [repoPath, loadRepoInfo]); /** Pre-indexed comment counts by group for O(1) lookup. */ const commentsByGroupMap = useMemo(() => { @@ -3378,12 +3500,13 @@ export default function App() { value={repoPath} onChange={(e) => { prRefs.current = false; + prUrlRef.current = null; setRepoPath(e.target.value); }} onKeyDown={(e) => { if (e.key === "Enter" && repoPath && !loading) { (e.target as HTMLInputElement).blur(); - submitRepoInput(); + startAnalysis(); } }} /> @@ -3472,12 +3595,65 @@ export default function App() { + {refining ? ( + + ) : ( + + )}
{!aiAccessReady && llmSettings && ( @@ -4053,6 +4229,27 @@ export default function App() {
)} + {/* New commits detected on the analyzed branch/PR — refresh on request, + never yank the groups out from under a review in progress. */} + {newCommitsAvailable && ( +
+ New commits + + {prUrlRef.current ? "This pull request was updated." : "This branch was updated."} + + + +
+ )} + {/* Three-panel layout */}
{/* Left panel: Flow Groups */} @@ -4075,34 +4272,6 @@ export default function App() { )}
- {/* Refinement banner — shown after analysis when LLM access is available */} - {analysis && !refinedGroups && !refining && aiAccessReady && ( -
- AI can improve these groupings - -
- )} - - {analysis && refining && ( -
- Refining groups… - -
- )} - {/* Manifest export & watch — allows CLI/agent refinement loop */} {analysis && IS_TAURI && !watchedManifestPath && (
diff --git a/crates/diffcore-tauri/ui/src/styles.css b/crates/diffcore-tauri/ui/src/styles.css index 513acbe..753d18f 100644 --- a/crates/diffcore-tauri/ui/src/styles.css +++ b/crates/diffcore-tauri/ui/src/styles.css @@ -1078,6 +1078,84 @@ body { background: var(--accent-hover); } +/* ── Analyze / Refine ── */ + +/* Fixed width so the label can swap between Analyze / Reanalyze / Analyzing… + / Confirm? without the top bar shifting under the pointer. */ +.btn-analyze { + min-width: 96px; +} + +.refine-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--text-primary); + cursor: pointer; + user-select: none; +} + +.refine-toggle input { + margin: 0; + cursor: pointer; +} + +.refine-toggle.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.refine-toggle.disabled input { + cursor: not-allowed; +} + +.btn-analyze-destructive { + background: var(--risk-high); + border-color: var(--risk-high); + color: var(--bg-primary); + font-weight: 600; +} + +.btn-analyze-destructive:hover:not(:disabled) { + background: rgba(var(--risk-high-rgb), 0.85); +} + +/* ── New commits bar ── */ + +.new-commits-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + background: rgba(var(--accent-rgb), 0.08); + border-bottom: 1px solid var(--border); + font-size: 12px; +} + +.new-commits-indicator { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: uppercase; + color: var(--accent); + padding: 2px 8px; + border-radius: 999px; + background: rgba(var(--accent-rgb), 0.15); + animation: activity-live-pulse 2s ease-in-out infinite; +} + +.new-commits-text { + flex: 1; + color: var(--text-secondary); +} + +.new-commits-bar .btn-close { + color: var(--text-muted); +} + /* ── Error Bar ── */ .error-bar { @@ -3680,20 +3758,6 @@ body { background: var(--accent-hover); } -.refinement-banner-running { - background: rgba(var(--risk-medium-rgb), 0.1); -} - -.btn-refine-cancel { - background: rgba(var(--risk-high-rgb), 0.95); - border-color: rgba(var(--risk-high-rgb), 0.95); - color: var(--bg-primary); -} - -.btn-refine-cancel:hover { - background: rgba(var(--risk-high-rgb), 0.78); -} - /* Refinement loading indicator */ .refinement-loading { display: flex; diff --git a/crates/diffcore-tauri/ui/src/types.ts b/crates/diffcore-tauri/ui/src/types.ts index 2f4450a..f6d830e 100644 --- a/crates/diffcore-tauri/ui/src/types.ts +++ b/crates/diffcore-tauri/ui/src/types.ts @@ -287,6 +287,10 @@ export interface RefinementResult { provider: string; model: string; had_changes: boolean; + /** Head commit the refinement was computed against; absent on legacy cache entries. */ + head_sha?: string | null; + /** File paths covered by the refinement (grouped + infrastructure). */ + files?: string[]; } /** Raw refinement response with structural operations. */ diff --git a/crates/diffcore-tauri/ui/tests/e2e/activity-stream.spec.ts b/crates/diffcore-tauri/ui/tests/e2e/activity-stream.spec.ts index a2af8f3..2a0686f 100644 --- a/crates/diffcore-tauri/ui/tests/e2e/activity-stream.spec.ts +++ b/crates/diffcore-tauri/ui/tests/e2e/activity-stream.spec.ts @@ -9,20 +9,32 @@ async function waitForDemoApp(page: Page) { async function setLlmSettings(page: Page, settings: Record) { await page.evaluate((value) => { - (window as { __TEST_API__: { setLlmSettings: (next: Record) => void } }).__TEST_API__.setLlmSettings(value); + (window as unknown as { __TEST_API__: { setLlmSettings: (next: Record) => void } }).__TEST_API__.setLlmSettings(value); }, settings); } async function setActivityEntries(page: Page, entries: Array>) { await page.evaluate((value) => { ( - window as { + window as unknown as { __TEST_API__: { setActivityEntries: (next: Array>) => void }; } ).__TEST_API__.setActivityEntries(value); }, entries); } +/** Trigger the LLM pass the way the top bar does it now: the Refine checkbox is + * on by default, so Analyze (confirmed) re-runs the analysis and chains the + * refinement onto it. Returns as the refinement starts streaming. */ +async function refineViaTopBar(page: Page) { + await expect(page.getByTestId("refine-checkbox")).toBeChecked(); + const analyze = page.getByTestId("analyze-btn"); + await analyze.click(); + await expect(analyze).toHaveText("Confirm?"); + await analyze.click(); + await expect(analyze).toHaveText("Reanalyze"); +} + test.describe("AI activity stream", () => { test.beforeEach(async ({ page }) => { await waitForDemoApp(page); @@ -65,7 +77,7 @@ test.describe("AI activity stream", () => { claude_authenticated: true, }); - await page.getByRole("button", { name: "Refine" }).click(); + await refineViaTopBar(page); const panel = page.getByTestId("activity-panel"); const log = page.getByTestId("activity-log"); diff --git a/crates/diffcore-tauri/ui/tests/e2e/group-metadata.spec.ts b/crates/diffcore-tauri/ui/tests/e2e/group-metadata.spec.ts index 5319914..f6bf1a4 100644 --- a/crates/diffcore-tauri/ui/tests/e2e/group-metadata.spec.ts +++ b/crates/diffcore-tauri/ui/tests/e2e/group-metadata.spec.ts @@ -14,6 +14,20 @@ async function waitForAnalysis(page: Page) { await expect(page.locator(".group-item.selected .file-list")).toBeVisible({ timeout: 5_000 }); } +/** Trigger the LLM pass the way the top bar does it now: the Refine checkbox is + * on by default, so Analyze (confirmed) re-runs the analysis and chains the + * refinement onto it. */ +async function refineViaTopBar(page: Page) { + await expect(page.getByTestId("refine-checkbox")).toBeChecked(); + const analyze = page.getByTestId("analyze-btn"); + await analyze.click(); + await expect(analyze).toHaveText("Confirm?"); + await analyze.click(); + await expect(analyze).toHaveText("Reanalyze"); + // The original/refined toggle only exists once the refinement has applied. + await expect(page.locator(".refinement-toggle")).toBeVisible({ timeout: 15_000 }); +} + /** The group with every metadata field populated. */ function fullGroup(page: Page): Locator { return page.locator(".group-item", { hasText: "POST /api/users creation flow" }); @@ -164,8 +178,7 @@ test.describe("Group metadata — right panel", () => { await expect(page.getByTestId("group-summary")).toBeVisible(); const before = await page.locator(".review-meta-description").textContent(); - await page.locator(".btn-refine").click(); - await page.waitForTimeout(2000); + await refineViaTopBar(page); await page.getByTestId("annotations-tab").click(); // Refinement rebuilds groups from scratch; the review metadata has to come @@ -177,8 +190,7 @@ test.describe("Group metadata — right panel", () => { }); test("13c — the refinement rationale stays collapsed instead of burying the group", async ({ page }) => { - await page.locator(".btn-refine").click(); - await page.waitForTimeout(2000); + await refineViaTopBar(page); await page.getByTestId("annotations-tab").click(); const verdict = page.getByTestId("refinement-verdict"); diff --git a/crates/diffcore-tauri/ui/tests/e2e/hardening.spec.ts b/crates/diffcore-tauri/ui/tests/e2e/hardening.spec.ts index 4fb0db6..ffc0c7d 100644 --- a/crates/diffcore-tauri/ui/tests/e2e/hardening.spec.ts +++ b/crates/diffcore-tauri/ui/tests/e2e/hardening.spec.ts @@ -555,6 +555,20 @@ test.describe("Hardening — LLM Annotations", () => { // Refinement // ═══════════════════════════════════════════════════════════════════ +/** Trigger the LLM pass the way the top bar does it now: the Refine checkbox is + * on by default, so Analyze (confirmed) re-runs the analysis and chains the + * refinement onto it. */ +async function refineViaTopBar(page: Page) { + await expect(page.getByTestId("refine-checkbox")).toBeChecked(); + const analyze = page.getByTestId("analyze-btn"); + await analyze.click(); + await expect(analyze).toHaveText("Confirm?"); + await analyze.click(); + await expect(analyze).toHaveText("Reanalyze"); + // The original/refined toggle only exists once the refinement has applied. + await expect(page.locator(".refinement-toggle")).toBeVisible({ timeout: 15_000 }); +} + test.describe("Hardening — Refinement", () => { test("31 — refinement banner visible when enabled", async ({ page }) => { await page.goto("/"); @@ -576,16 +590,45 @@ test.describe("Hardening — Refinement", () => { }); await page.waitForTimeout(300); - // Verify refinement banner is shown - await expect(page.locator(".refinement-banner")).toBeVisible(); - await expect(page.locator(".refinement-banner")).toContainText("AI can improve"); - await expect(page.locator(".btn-refine")).toBeVisible(); + // The LLM pass rides along with Analyze via a pre-checked box, so the + // single-click path is the default one. + const toggle = page.getByTestId("refine-toggle"); + await expect(toggle).toBeVisible(); + await expect(toggle).toContainText("Refine"); + await expect(page.getByTestId("refine-checkbox")).toBeChecked(); - await page.locator(".refinement-banner").screenshot({ + await page.locator(".top-bar-left").screenshot({ path: path.join(SCREENSHOTS_DIR, "31-refinement-banner.png"), }); }); + test("31b — new commits bar appears and Refresh re-analyzes", async ({ page }) => { + await page.goto("/"); + await waitForAnalysis(page); + + await page.evaluate(() => { + (window as any).__TEST_API__.showNewCommits(true); + }); + const bar = page.getByTestId("new-commits-bar"); + await expect(bar).toBeVisible(); + await expect(bar).toContainText("New commits"); + + // New commits upstream are the one case that turns Analyze red, so stale + // groups are obvious without reading the bar. + const analyze = page.getByTestId("analyze-btn"); + await expect(analyze).toHaveClass(/btn-analyze-destructive/); + await expect(analyze).toHaveText("Reanalyze"); + + await bar.screenshot({ + path: path.join(SCREENSHOTS_DIR, "31b-new-commits-bar.png"), + }); + + // Refresh re-runs analysis and clears the bar + await bar.getByRole("button", { name: "Refresh" }).click(); + await expect(bar).not.toBeVisible(); + await waitForAnalysis(page); + }); + test("32 — refinement: complete with original/refined toggle", async ({ page }) => { await page.goto("/"); await waitForAnalysis(page); @@ -607,8 +650,7 @@ test.describe("Hardening — Refinement", () => { await page.waitForTimeout(300); // Click Refine - await page.locator(".btn-refine").click(); - await page.waitForTimeout(2000); // Mock delay is 1200ms + await refineViaTopBar(page); // Verify toggle exists await expect(page.locator(".refinement-toggle")).toBeVisible(); @@ -643,8 +685,7 @@ test.describe("Hardening — Refinement", () => { }); await page.waitForTimeout(300); - await page.locator(".btn-refine").click(); - await page.waitForTimeout(2000); + await refineViaTopBar(page); // Verify change indicators are present const changeIndicators = page.locator(".change-indicator"); @@ -677,8 +718,7 @@ test.describe("Hardening — Refinement", () => { }); await page.waitForTimeout(300); - await page.locator(".btn-refine").click(); - await page.waitForTimeout(2000); + await refineViaTopBar(page); // Switch to Original view await page.locator(".toggle-btn").filter({ hasText: "Original" }).click(); @@ -716,8 +756,7 @@ test.describe("Hardening — Refinement", () => { }); await page.waitForTimeout(300); - await page.locator(".btn-refine").click(); - await page.waitForTimeout(2000); + await refineViaTopBar(page); // Refining must not cost the user their PR overview await page.getByTestId("annotations-tab").click(); @@ -746,8 +785,7 @@ test.describe("Hardening — Refinement", () => { }); await page.waitForTimeout(300); - await page.locator(".btn-refine").click(); - await page.waitForTimeout(2000); + await refineViaTopBar(page); const groupList = page.getByTestId("group-list"); diff --git a/crates/diffcore-tauri/ui/tests/e2e/onboarding.spec.ts b/crates/diffcore-tauri/ui/tests/e2e/onboarding.spec.ts index 4de8f23..186932a 100644 --- a/crates/diffcore-tauri/ui/tests/e2e/onboarding.spec.ts +++ b/crates/diffcore-tauri/ui/tests/e2e/onboarding.spec.ts @@ -9,7 +9,7 @@ async function waitForDemoApp(page: Page) { async function setLlmSettings(page: Page, settings: Record) { await page.evaluate((value) => { - (window as { __TEST_API__: { setLlmSettings: (settings: Record) => void } }).__TEST_API__.setLlmSettings(value); + (window as unknown as { __TEST_API__: { setLlmSettings: (settings: Record) => void } }).__TEST_API__.setLlmSettings(value); }, settings); } @@ -62,7 +62,7 @@ test.describe("AI onboarding", () => { await expect(page.locator(".llm-provider-badge")).toContainText("Codex CLI/default"); await page.evaluate(() => { - (window as { __TEST_API__: { openAiSetup: (step?: "recommended" | "api") => void } }).__TEST_API__.openAiSetup("recommended"); + (window as unknown as { __TEST_API__: { openAiSetup: (step?: "recommended" | "api") => void } }).__TEST_API__.openAiSetup("recommended"); }); const onboarding = page.getByTestId("ai-onboarding"); diff --git a/crates/diffcore-tauri/ui/tsconfig.e2e.json b/crates/diffcore-tauri/ui/tsconfig.e2e.json new file mode 100644 index 0000000..9d983f6 --- /dev/null +++ b/crates/diffcore-tauri/ui/tsconfig.e2e.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["tests"] +} diff --git a/docs/screenshots/01-loaded-analysis.png b/docs/screenshots/01-loaded-analysis.png index 962e997..b54f491 100644 Binary files a/docs/screenshots/01-loaded-analysis.png and b/docs/screenshots/01-loaded-analysis.png differ diff --git a/docs/screenshots/02-flow-groups-panel.png b/docs/screenshots/02-flow-groups-panel.png index 465925a..f573d95 100644 Binary files a/docs/screenshots/02-flow-groups-panel.png and b/docs/screenshots/02-flow-groups-panel.png differ diff --git a/docs/screenshots/03-diff-viewer.png b/docs/screenshots/03-diff-viewer.png index 4bb9bbe..6ce50b5 100644 Binary files a/docs/screenshots/03-diff-viewer.png and b/docs/screenshots/03-diff-viewer.png differ diff --git a/docs/screenshots/04-annotations-panel.png b/docs/screenshots/04-annotations-panel.png index c31b87d..d61deb1 100644 Binary files a/docs/screenshots/04-annotations-panel.png and b/docs/screenshots/04-annotations-panel.png differ diff --git a/docs/screenshots/05-second-group-selected.png b/docs/screenshots/05-second-group-selected.png index 6e48253..ff0a726 100644 Binary files a/docs/screenshots/05-second-group-selected.png and b/docs/screenshots/05-second-group-selected.png differ diff --git a/docs/screenshots/06-third-group-low-risk.png b/docs/screenshots/06-third-group-low-risk.png index 4312e94..f3459c7 100644 Binary files a/docs/screenshots/06-third-group-low-risk.png and b/docs/screenshots/06-third-group-low-risk.png differ diff --git a/docs/screenshots/07-second-file-selected.png b/docs/screenshots/07-second-file-selected.png index 05420e6..525a47d 100644 Binary files a/docs/screenshots/07-second-file-selected.png and b/docs/screenshots/07-second-file-selected.png differ diff --git a/docs/screenshots/08-keyboard-navigation.png b/docs/screenshots/08-keyboard-navigation.png index 6f074f8..80639c7 100644 Binary files a/docs/screenshots/08-keyboard-navigation.png and b/docs/screenshots/08-keyboard-navigation.png differ diff --git a/docs/screenshots/09-group-keyboard-navigation.png b/docs/screenshots/09-group-keyboard-navigation.png index 5b3c9b5..e779884 100644 Binary files a/docs/screenshots/09-group-keyboard-navigation.png and b/docs/screenshots/09-group-keyboard-navigation.png differ diff --git a/docs/screenshots/10-top-bar.png b/docs/screenshots/10-top-bar.png index 0ce9c03..7916df1 100644 Binary files a/docs/screenshots/10-top-bar.png and b/docs/screenshots/10-top-bar.png differ diff --git a/docs/screenshots/11-flow-graph.png b/docs/screenshots/11-flow-graph.png index 332e267..93e83d9 100644 Binary files a/docs/screenshots/11-flow-graph.png and b/docs/screenshots/11-flow-graph.png differ diff --git a/docs/screenshots/12-error-state.png b/docs/screenshots/12-error-state.png index e404f4d..4c49aa5 100644 Binary files a/docs/screenshots/12-error-state.png and b/docs/screenshots/12-error-state.png differ diff --git a/docs/screenshots/15-branch-dropdown-open.png b/docs/screenshots/15-branch-dropdown-open.png index bcb9c9a..646cfc1 100644 Binary files a/docs/screenshots/15-branch-dropdown-open.png and b/docs/screenshots/15-branch-dropdown-open.png differ diff --git a/docs/screenshots/17-branch-dropdown-many.png b/docs/screenshots/17-branch-dropdown-many.png index f426218..8ae83d4 100644 Binary files a/docs/screenshots/17-branch-dropdown-many.png and b/docs/screenshots/17-branch-dropdown-many.png differ diff --git a/docs/screenshots/22-settings-panel.png b/docs/screenshots/22-settings-panel.png index 80b668e..1a537d7 100644 Binary files a/docs/screenshots/22-settings-panel.png and b/docs/screenshots/22-settings-panel.png differ diff --git a/docs/screenshots/27-summarize-idle.png b/docs/screenshots/27-summarize-idle.png index f7ab182..dd6eb9d 100644 Binary files a/docs/screenshots/27-summarize-idle.png and b/docs/screenshots/27-summarize-idle.png differ diff --git a/docs/screenshots/28-summarize-complete.png b/docs/screenshots/28-summarize-complete.png index ee481c3..f06760c 100644 Binary files a/docs/screenshots/28-summarize-complete.png and b/docs/screenshots/28-summarize-complete.png differ diff --git a/docs/screenshots/29-deep-analysis-complete.png b/docs/screenshots/29-deep-analysis-complete.png index b991b29..3848be0 100644 Binary files a/docs/screenshots/29-deep-analysis-complete.png and b/docs/screenshots/29-deep-analysis-complete.png differ diff --git a/docs/screenshots/30-buttons-no-api-key.png b/docs/screenshots/30-buttons-no-api-key.png index 0d28298..2a38ae0 100644 Binary files a/docs/screenshots/30-buttons-no-api-key.png and b/docs/screenshots/30-buttons-no-api-key.png differ diff --git a/docs/screenshots/31-refinement-banner.png b/docs/screenshots/31-refinement-banner.png index fe96ff2..69f49eb 100644 Binary files a/docs/screenshots/31-refinement-banner.png and b/docs/screenshots/31-refinement-banner.png differ diff --git a/docs/screenshots/31b-new-commits-bar.png b/docs/screenshots/31b-new-commits-bar.png new file mode 100644 index 0000000..214f5e7 Binary files /dev/null and b/docs/screenshots/31b-new-commits-bar.png differ diff --git a/docs/screenshots/32-refinement-complete.png b/docs/screenshots/32-refinement-complete.png index 731bcd7..c268d7b 100644 Binary files a/docs/screenshots/32-refinement-complete.png and b/docs/screenshots/32-refinement-complete.png differ diff --git a/docs/screenshots/35-graph-node-selected.png b/docs/screenshots/35-graph-node-selected.png index e43a2a7..82d3bee 100644 Binary files a/docs/screenshots/35-graph-node-selected.png and b/docs/screenshots/35-graph-node-selected.png differ diff --git a/docs/screenshots/36-graph-legend-expanded.png b/docs/screenshots/36-graph-legend-expanded.png index 390ee5e..c86a7d5 100644 Binary files a/docs/screenshots/36-graph-legend-expanded.png and b/docs/screenshots/36-graph-legend-expanded.png differ diff --git a/docs/screenshots/37-graph-fullscreen.png b/docs/screenshots/37-graph-fullscreen.png index 94b68df..688c626 100644 Binary files a/docs/screenshots/37-graph-fullscreen.png and b/docs/screenshots/37-graph-fullscreen.png differ diff --git a/docs/screenshots/39-single-node-no-graph.png b/docs/screenshots/39-single-node-no-graph.png index 5ad4ccc..5eaaea0 100644 Binary files a/docs/screenshots/39-single-node-no-graph.png and b/docs/screenshots/39-single-node-no-graph.png differ diff --git a/docs/screenshots/40-empty-state.png b/docs/screenshots/40-empty-state.png index b8bc66e..5cb8b0b 100644 Binary files a/docs/screenshots/40-empty-state.png and b/docs/screenshots/40-empty-state.png differ diff --git a/docs/screenshots/41-error-state-real.png b/docs/screenshots/41-error-state-real.png index 9944a63..73070ab 100644 Binary files a/docs/screenshots/41-error-state-real.png and b/docs/screenshots/41-error-state-real.png differ diff --git a/docs/screenshots/43-large-dataset.png b/docs/screenshots/43-large-dataset.png index 69ed316..0f19b65 100644 Binary files a/docs/screenshots/43-large-dataset.png and b/docs/screenshots/43-large-dataset.png differ diff --git a/docs/screenshots/44-large-dataset-scrolled.png b/docs/screenshots/44-large-dataset-scrolled.png index 1c62e74..3534a56 100644 Binary files a/docs/screenshots/44-large-dataset-scrolled.png and b/docs/screenshots/44-large-dataset-scrolled.png differ diff --git a/docs/screenshots/45-large-infra-group.png b/docs/screenshots/45-large-infra-group.png index df6bc6f..fc40a7b 100644 Binary files a/docs/screenshots/45-large-infra-group.png and b/docs/screenshots/45-large-infra-group.png differ diff --git a/docs/screenshots/46-responsive-narrow.png b/docs/screenshots/46-responsive-narrow.png index 13bdf87..4997d70 100644 Binary files a/docs/screenshots/46-responsive-narrow.png and b/docs/screenshots/46-responsive-narrow.png differ diff --git a/docs/screenshots/47-responsive-wide.png b/docs/screenshots/47-responsive-wide.png index db9bd88..bb1a082 100644 Binary files a/docs/screenshots/47-responsive-wide.png and b/docs/screenshots/47-responsive-wide.png differ diff --git a/docs/screenshots/48-responsive-minimum.png b/docs/screenshots/48-responsive-minimum.png index 379a63b..3e50441 100644 Binary files a/docs/screenshots/48-responsive-minimum.png and b/docs/screenshots/48-responsive-minimum.png differ diff --git a/docs/screenshots/49-pr-preview-default.png b/docs/screenshots/49-pr-preview-default.png index 0ce9c03..7916df1 100644 Binary files a/docs/screenshots/49-pr-preview-default.png and b/docs/screenshots/49-pr-preview-default.png differ diff --git a/docs/screenshots/50-pr-preview-switched-branch.png b/docs/screenshots/50-pr-preview-switched-branch.png index aa80d11..40757d2 100644 Binary files a/docs/screenshots/50-pr-preview-switched-branch.png and b/docs/screenshots/50-pr-preview-switched-branch.png differ diff --git a/docs/screenshots/51-replay-active.png b/docs/screenshots/51-replay-active.png index 217bd67..b56223c 100644 Binary files a/docs/screenshots/51-replay-active.png and b/docs/screenshots/51-replay-active.png differ diff --git a/docs/screenshots/52-replay-step-2.png b/docs/screenshots/52-replay-step-2.png index 3cfb736..87fa670 100644 Binary files a/docs/screenshots/52-replay-step-2.png and b/docs/screenshots/52-replay-step-2.png differ diff --git a/docs/screenshots/53-replay-visited-checks.png b/docs/screenshots/53-replay-visited-checks.png index 3cfb736..87fa670 100644 Binary files a/docs/screenshots/53-replay-visited-checks.png and b/docs/screenshots/53-replay-visited-checks.png differ diff --git a/docs/screenshots/54-replay-last-step.png b/docs/screenshots/54-replay-last-step.png index f6973df..6176adf 100644 Binary files a/docs/screenshots/54-replay-last-step.png and b/docs/screenshots/54-replay-last-step.png differ diff --git a/docs/screenshots/60-analysis-loaded.png b/docs/screenshots/60-analysis-loaded.png index 962e997..b54f491 100644 Binary files a/docs/screenshots/60-analysis-loaded.png and b/docs/screenshots/60-analysis-loaded.png differ diff --git a/docs/screenshots/69-flow-groups-panel.png b/docs/screenshots/69-flow-groups-panel.png index 465925a..f573d95 100644 Binary files a/docs/screenshots/69-flow-groups-panel.png and b/docs/screenshots/69-flow-groups-panel.png differ diff --git a/docs/screenshots/70-second-group.png b/docs/screenshots/70-second-group.png index 3990a2d..7371695 100644 Binary files a/docs/screenshots/70-second-group.png and b/docs/screenshots/70-second-group.png differ diff --git a/docs/screenshots/71-replay-mode.png b/docs/screenshots/71-replay-mode.png index 217bd67..b56223c 100644 Binary files a/docs/screenshots/71-replay-mode.png and b/docs/screenshots/71-replay-mode.png differ diff --git a/docs/screenshots/72-annotations-panel.png b/docs/screenshots/72-annotations-panel.png index 0a4e19f..f06760c 100644 Binary files a/docs/screenshots/72-annotations-panel.png and b/docs/screenshots/72-annotations-panel.png differ diff --git a/docs/screenshots/80-editor-no-comments.png b/docs/screenshots/80-editor-no-comments.png index 962e997..b54f491 100644 Binary files a/docs/screenshots/80-editor-no-comments.png and b/docs/screenshots/80-editor-no-comments.png differ diff --git a/docs/screenshots/85-glyph-after-comment.png b/docs/screenshots/85-glyph-after-comment.png index 2fd225f..117bff8 100644 Binary files a/docs/screenshots/85-glyph-after-comment.png and b/docs/screenshots/85-glyph-after-comment.png differ diff --git a/docs/screenshots/86-glyph-hover.png b/docs/screenshots/86-glyph-hover.png index 83ca98b..1513418 100644 Binary files a/docs/screenshots/86-glyph-hover.png and b/docs/screenshots/86-glyph-hover.png differ diff --git a/docs/screenshots/87-glyph-click-activates-comment.png b/docs/screenshots/87-glyph-click-activates-comment.png index 20390ea..c0788dd 100644 Binary files a/docs/screenshots/87-glyph-click-activates-comment.png and b/docs/screenshots/87-glyph-click-activates-comment.png differ diff --git a/docs/screenshots/annotations-panel.png b/docs/screenshots/annotations-panel.png index 0a4e19f..f06760c 100644 Binary files a/docs/screenshots/annotations-panel.png and b/docs/screenshots/annotations-panel.png differ diff --git a/docs/screenshots/comments-gutter.png b/docs/screenshots/comments-gutter.png index 84daf2f..7be1d0e 100644 Binary files a/docs/screenshots/comments-gutter.png and b/docs/screenshots/comments-gutter.png differ diff --git a/docs/screenshots/hero-analysis.png b/docs/screenshots/hero-analysis.png index eb06825..b54f491 100644 Binary files a/docs/screenshots/hero-analysis.png and b/docs/screenshots/hero-analysis.png differ diff --git a/docs/screenshots/replay-mode.png b/docs/screenshots/replay-mode.png index 217bd67..b56223c 100644 Binary files a/docs/screenshots/replay-mode.png and b/docs/screenshots/replay-mode.png differ diff --git a/docs/screenshots/second-group.png b/docs/screenshots/second-group.png index 3990a2d..7371695 100644 Binary files a/docs/screenshots/second-group.png and b/docs/screenshots/second-group.png differ