diff --git a/crates/diffcore-core/src/llm/refinement.rs b/crates/diffcore-core/src/llm/refinement.rs index a2f7a46..a12a311 100644 --- a/crates/diffcore-core/src/llm/refinement.rs +++ b/crates/diffcore-core/src/llm/refinement.rs @@ -713,6 +713,97 @@ pub fn has_refinements(response: &RefinementResponse) -> bool { || !response.reclassifications.is_empty() } +/// 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: HashSet<&str> = fresh + .groups + .iter() + .flat_map(|g| g.files.iter().map(|f| f.path.as_str())) + .chain( + fresh + .infrastructure_group + .iter() + .flat_map(|ig| ig.files.iter().map(String::as_str)), + ) + .collect(); + let fresh_grouped: HashSet<&str> = fresh + .groups + .iter() + .flat_map(|g| g.files.iter().map(|f| f.path.as_str())) + .collect(); + let prev_paths: HashSet<&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(); + + 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 +2567,135 @@ 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 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 a3c5e7f..06b4d6e 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 89fa27e..65ebfad 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,87 @@ 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, +/// incremental falls through to a from-scratch refine. +/// 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); + } + match load_previous_refinement(state, repo_path) { + Some(prev) => { + let (carried, delta) = refinement::carry_over_grouping( + &analysis, + &prev.refined_groups, + prev.infrastructure_group.as_ref(), + ); + (carried, delta, true) + } + None => (analysis, summary, false), + } } /// Store a refinement result in the global cache (~/.diffcore/cache/refinements/). @@ -3008,6 +3127,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(), @@ -3041,6 +3206,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"); @@ -3364,6 +3548,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(); @@ -3419,6 +3605,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 54a4041..08c9ca4 100644 --- a/crates/diffcore-tauri/src/main.rs +++ b/crates/diffcore-tauri/src/main.rs @@ -100,6 +100,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 76de917..72d3782 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")?; @@ -410,7 +413,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 1c197bd..2ccd5c4 100644 --- a/crates/diffcore-tauri/ui/src/App.tsx +++ b/crates/diffcore-tauri/ui/src/App.tsx @@ -224,6 +224,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); @@ -259,6 +265,9 @@ export default function App() { * detached, so letting loadRepoInfo auto-detect would replace the PR's fork * point and tip with the checkout's default branch and a bare HEAD. */ const prRefs = useRef(false); + /** 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); @@ -328,6 +337,11 @@ export default function App() { const groupListTransitionTimers = useRef([]); const [refining, setRefining] = useState(false); const refinementJobIdRef = useRef(null); + // Whether "Refine (update)" has a baseline to build on. Cleared on every + // re-analyze; the disk-cache lookup after analysis re-seeds it. + const [hasRefinementBaseline, setHasRefinementBaseline] = useState(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); @@ -536,11 +550,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(() => {}); }; @@ -635,6 +657,7 @@ export default function App() { await new Promise((resolve, reject) => { const source = new EventSource(start.stream_url); activitySourceRef.current = source; + activityAbortRef.current = () => reject(new Error("Cancelled by user")); source.addEventListener("job_started", (event) => { try { @@ -952,6 +975,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({}); @@ -969,6 +1000,8 @@ export default function App() { setRefinementModel(null); setRefinementHadChanges(null); setShowRefined(false); + setHasRefinementBaseline(false); + setNewCommitsAvailable(false); // Reset review tick-off state setReviewedGroupIds(new Set()); setDismissedEmptyGroupIds(new Set()); @@ -1011,11 +1044,33 @@ 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 + // becomes the baseline for "Refine (update)" instead. 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 }); + } else { + setHasRefinementBaseline(true); } }).catch(() => {}); } @@ -1054,6 +1109,7 @@ export default function App() { return; } prRefs.current = true; + prUrlRef.current = value; setRepoPath(resolved.path); setBaseRef(resolved.base); setHeadRef(resolved.head); @@ -1061,6 +1117,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(() => { @@ -1191,6 +1274,8 @@ export default function App() { const applyRefinementResult = useCallback((result: RefinementResult, opts?: { fromCache?: boolean }) => { if (!analysis) return; + setHasRefinementBaseline(true); + if (!originalGroups) { setOriginalGroups(analysis.groups); } @@ -1240,8 +1325,9 @@ export default function App() { } }, [analysis, originalGroups, 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); @@ -1251,6 +1337,7 @@ export default function App() { repoPath: repoPath || null, llmProvider: resolvedRefinementProvider, llmModel: resolvedRefinementModel, + incremental: opts?.incremental ?? false, }, (result) => { applyRefinementResult(result); }, (jobId) => { @@ -1384,6 +1471,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); 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(), @@ -2121,38 +2209,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(() => { @@ -3148,6 +3243,7 @@ export default function App() { value={repoPath} onChange={(e) => { prRefs.current = false; + prUrlRef.current = null; setRepoPath(e.target.value); }} onKeyDown={(e) => { @@ -3242,12 +3338,56 @@ export default function App() { + {refining ? ( + + ) : ( + + )}
{!aiAccessReady && llmSettings && ( @@ -3823,6 +3963,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 */} @@ -3845,34 +4006,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 874186d..65764a6 100644 --- a/crates/diffcore-tauri/ui/src/styles.css +++ b/crates/diffcore-tauri/ui/src/styles.css @@ -1078,6 +1078,54 @@ body { background: var(--accent-hover); } +/* ── Analyze / Refine ── */ + +.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 { @@ -3673,20 +3721,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 5e22f59..d120cea 100644 --- a/crates/diffcore-tauri/ui/src/types.ts +++ b/crates/diffcore-tauri/ui/src/types.ts @@ -277,6 +277,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 50b2528..84793f2 100644 --- a/crates/diffcore-tauri/ui/tests/e2e/activity-stream.spec.ts +++ b/crates/diffcore-tauri/ui/tests/e2e/activity-stream.spec.ts @@ -9,14 +9,14 @@ 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); @@ -64,7 +64,7 @@ test.describe("AI activity stream", () => { claude_authenticated: true, }); - await page.getByRole("button", { name: "Refine" }).click(); + await page.getByTestId("refine-btn").click(); 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..b8249eb 100644 --- a/crates/diffcore-tauri/ui/tests/e2e/group-metadata.spec.ts +++ b/crates/diffcore-tauri/ui/tests/e2e/group-metadata.spec.ts @@ -164,7 +164,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.getByTestId("refine-btn").click(); await page.waitForTimeout(2000); await page.getByTestId("annotations-tab").click(); @@ -177,7 +177,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.getByTestId("refine-btn").click(); await page.waitForTimeout(2000); await page.getByTestId("annotations-tab").click(); diff --git a/crates/diffcore-tauri/ui/tests/e2e/hardening.spec.ts b/crates/diffcore-tauri/ui/tests/e2e/hardening.spec.ts index a696bf5..e148be6 100644 --- a/crates/diffcore-tauri/ui/tests/e2e/hardening.spec.ts +++ b/crates/diffcore-tauri/ui/tests/e2e/hardening.spec.ts @@ -575,16 +575,36 @@ 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 top-bar Refine button becomes available after analysis completes + await expect(page.getByTestId("refine-btn")).toBeVisible(); + await expect(page.getByTestId("refine-btn")).toContainText("Refine"); - await page.locator(".refinement-banner").screenshot({ + await page.getByTestId("refine-btn").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"); + + 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); @@ -606,7 +626,7 @@ test.describe("Hardening — Refinement", () => { await page.waitForTimeout(300); // Click Refine - await page.locator(".btn-refine").click(); + await page.getByTestId("refine-btn").click(); await page.waitForTimeout(2000); // Mock delay is 1200ms // Verify toggle exists @@ -642,7 +662,7 @@ test.describe("Hardening — Refinement", () => { }); await page.waitForTimeout(300); - await page.locator(".btn-refine").click(); + await page.getByTestId("refine-btn").click(); await page.waitForTimeout(2000); // Verify change indicators are present @@ -676,7 +696,7 @@ test.describe("Hardening — Refinement", () => { }); await page.waitForTimeout(300); - await page.locator(".btn-refine").click(); + await page.getByTestId("refine-btn").click(); await page.waitForTimeout(2000); // Switch to Original view @@ -715,7 +735,7 @@ test.describe("Hardening — Refinement", () => { }); await page.waitForTimeout(300); - await page.locator(".btn-refine").click(); + await page.getByTestId("refine-btn").click(); await page.waitForTimeout(2000); // Refining must not cost the user their PR overview @@ -745,7 +765,7 @@ test.describe("Hardening — Refinement", () => { }); await page.waitForTimeout(300); - await page.locator(".btn-refine").click(); + await page.getByTestId("refine-btn").click(); await page.waitForTimeout(2000); 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 ad59e0b..c7b9ebd 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); } @@ -61,7 +61,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..b7932c4 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/04-annotations-panel.png b/docs/screenshots/04-annotations-panel.png index c31b87d..b423419 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..5b1a5c4 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..2e2ca8c 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..8fcc0ab 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..c22f42d 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..6cb542f 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..5e08340 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..e3923e5 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..98db867 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..16234d3 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..7618fba 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..6d199f6 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..827344d 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..a01e545 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..b227280 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..9356aae 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..7ad00f5 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..391cc05 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/41-error-state-real.png b/docs/screenshots/41-error-state-real.png index 9944a63..adda3ff 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..3481ab9 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..cce6de5 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/46-responsive-narrow.png b/docs/screenshots/46-responsive-narrow.png index 13bdf87..58ca76b 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..baa3753 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..c597649 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..5e08340 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..6a38aa4 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..39fd915 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..3684f6e 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..7832916 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..84e5855 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..b7932c4 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..966104e 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..416be18 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..b7932c4 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/86-glyph-hover.png b/docs/screenshots/86-glyph-hover.png index 83ca98b..68239ac 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..c6844c4 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..18c7f03 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..b7932c4 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..416be18 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..8d0ada8 100644 Binary files a/docs/screenshots/second-group.png and b/docs/screenshots/second-group.png differ