diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index cb5b4d90b..0b72241b0 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -1343,6 +1343,15 @@ impl AgentManager { blocking(move || at.changes(&before)).await } + /// The task's uncommitted changes and, for a worktree task, the commits made since it + /// started (newest first). Both are read against the same `HEAD`. + pub async fn grouped_changes(&self, id: &str) -> IdeResult { + let task = self.task(id)?; + let at = task.base(); + let before = task.state.lock().before.clone(); + blocking(move || at.grouped_changes(&before)).await + } + /// Content of `path` (in the task's directory) when the task started. pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult> { let at = self.task(id)?.base(); @@ -1353,9 +1362,14 @@ impl AgentManager { .await } - /// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they - /// were when it started (files the user had changed then, as the user left them). - pub async fn rollback_changes(&self, id: &str, rel_paths: Option>) -> IdeResult<()> { + /// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as + /// `view` compares them: as they were when it started, or as they are in `HEAD`. + pub async fn rollback_changes( + &self, + id: &str, + rel_paths: Option>, + view: AgentChangesView, + ) -> IdeResult<()> { let task = self.task(id)?; if task.state.lock().busy { return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes")); @@ -1366,8 +1380,10 @@ impl AgentManager { (s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name))) }; let restamped = blocking(move || { - let changes = at.changes(&before)?; - let rev = at.rev()?; + let (changes, rev) = match view { + AgentChangesView::Group => (at.grouped_changes(&before)?.uncommitted, worktree::head_commit(at.cwd())?), + AgentChangesView::SinceStart => (at.changes(&before)?, at.rev()?), + }; let sources = match rev.as_deref() { Some(rev) => worktree::rename_sources(at.cwd(), rev)?, None => Default::default(), diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index a4e1cddd3..9463ac6fb 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -484,7 +484,8 @@ pub struct NewAgentTask { pub base: Option, } -/// A file the task changed, relative to [`AgentTask::base_commit`]. +/// A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the +/// grouped view. #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct AgentChange { @@ -493,6 +494,44 @@ pub struct AgentChange { pub kind: ide_vcs::ChangeKind, pub added: u32, pub removed: u32, + /// Path before a rename; the left side of its diff reads this path. + pub old_path: Option, +} + +/// A commit made while the task ran, with the files it changed against its first parent. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentCommit { + pub id: String, + pub short_id: String, + pub summary: String, + pub message: String, + pub time_ms: f64, + /// First parent, or `None` for a root commit. + pub parent: Option, + pub files: Vec, +} + +/// Which comparison a rollback of the task's changes uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AgentChangesView { + /// Everything since the commit the task started from. + #[default] + SinceStart, + /// The uncommitted files against `HEAD`; commits made since the start stay. + Group, +} + +/// A task's uncommitted files and the commits made since it started. +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentChanges { + /// Files changed against `HEAD`, less those changed before the task started and untouched since. + pub uncommitted: Vec, + /// Commits from the task's start (exclusive) to `HEAD` (inclusive), newest first. Empty + /// for tasks working in the project's own checkout. + pub commits: Vec, } /// Streamed to the window that subscribed (`AgentManager::subscribe`). diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 9edf8f950..05671feb0 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -2853,14 +2853,14 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi } assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt", "other.txt"]); - mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).await.unwrap(); + mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()]), AgentChangesView::SinceStart).await.unwrap(); assert_eq!(read("other.txt"), "base\n"); assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt"]); // After a restart, from the saved copy. mgr.shutdown(); let mgr = manager(&dir, Arc::new(FakeAgent::default())); - mgr.rollback_changes(&task.id, None).await.unwrap(); + mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap(); assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays"); assert!(!dir.join("new.txt").exists()); assert!(changed(&mgr, &task.id).await.is_empty()); @@ -2973,7 +2973,7 @@ async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() { let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap(); assert_eq!(base.map(|b| b.replace("\r\n", "\n")).as_deref(), Some("base\n"), "{eol:?}"); - mgr.rollback_changes(&task.id, None).await.unwrap(); + mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap(); assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}"); assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}: the user's work survives the rollback"); assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}"); @@ -2996,7 +2996,7 @@ async fn files_a_mixed_reset_during_a_local_task_leaves_behind_are_not_rolled_ba repo.git(&["reset", "-q", "HEAD~1"]); assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}"); - mgr.rollback_changes(&task.id, None).await.unwrap(); + mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap(); assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}"); assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}"); assert_eq!(repo.read("mine-new.txt"), "my new file\n", "{eol:?}"); @@ -3023,7 +3023,7 @@ async fn a_branch_switched_to_during_a_local_task_is_left_alone() { repo.git(&["checkout", "-q", "feature"]); assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}"); - mgr.rollback_changes(&task.id, None).await.unwrap(); + mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap(); assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}"); assert_eq!(repo.read("other.txt"), "feature\n", "{eol:?}"); assert_eq!(repo.read("only-feature.txt"), "feature\n", "{eol:?}"); @@ -3048,7 +3048,7 @@ async fn commits_made_during_a_local_task_are_kept_by_its_rollback() { repo.write("a.txt", "agent\nagain\n"); assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"], "{eol:?}"); - mgr.rollback_changes(&task.id, None).await.unwrap(); + mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap(); assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to before it"); assert!(!repo.dir.join("b.txt").exists(), "{eol:?}"); assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}"); @@ -3069,7 +3069,7 @@ async fn rolling_back_a_rename_brings_the_old_file_back() { let listed = changed(&mgr, &task.id).await; assert_eq!(listed, ["CASE.java", "New.java"], "{eol:?}"); - mgr.rollback_changes(&task.id, Some(listed)).await.unwrap(); + mgr.rollback_changes(&task.id, Some(listed), AgentChangesView::SinceStart).await.unwrap(); assert_eq!(repo.read("Old.java"), "class Old {}\n", "{eol:?}"); assert!(!repo.dir.join("New.java").exists(), "{eol:?}"); assert_eq!(repo.read("Case.java"), "class Case {}\n", "{eol:?}"); @@ -3082,6 +3082,27 @@ async fn rolling_back_a_rename_brings_the_old_file_back() { } } +#[tokio::test(flavor = "multi_thread")] +async fn the_group_view_rolls_back_only_uncommitted_files() { + for eol in EOLS { + let repo = Checkout::new(eol); + repo.write("a.txt", "base\n"); + repo.commit("init"); + let (mgr, task) = repo.task().await; + repo.write("a.txt", "agent\n"); + repo.commit("agent work"); + repo.write("a.txt", "agent\nagain\n"); + + let grouped = mgr.grouped_changes(&task.id).await.unwrap(); + assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::>(), ["a.txt"], "{eol:?}"); + assert!(grouped.commits.is_empty(), "{eol:?}: local tasks have no commits to group"); + + mgr.rollback_changes(&task.id, None, AgentChangesView::Group).await.unwrap(); + assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to the start"); + assert!(mgr.grouped_changes(&task.id).await.unwrap().uncommitted.is_empty(), "{eol:?}"); + } +} + #[tokio::test(flavor = "multi_thread")] async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() { for eol in EOLS { @@ -3092,7 +3113,7 @@ async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_c assert_eq!(changed(&mgr, &task.id).await, ["new.txt"], "{eol:?}"); assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None, "{eol:?}"); - mgr.rollback_changes(&task.id, None).await.unwrap(); + mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap(); assert!(!repo.dir.join("new.txt").exists(), "{eol:?}"); assert_eq!(repo.read("mine.txt"), "mine\n", "{eol:?}"); assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}"); diff --git a/crates/ide-agent/src/worktree.rs b/crates/ide-agent/src/worktree.rs index ec4a289c6..72ba21f00 100644 --- a/crates/ide-agent/src/worktree.rs +++ b/crates/ide-agent/src/worktree.rs @@ -1,8 +1,8 @@ //! Task worktrees and the changes made in them. -use crate::model::AgentChange; +use crate::model::{AgentChange, AgentChanges, AgentCommit}; use ide_core::{IdeError, IdeResult, IpcPath}; -use ide_vcs::{ChangeKind, Repo}; +use ide_vcs::{ChangeKind, LogQuery, Repo}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::time::{Duration, UNIX_EPOCH}; @@ -35,6 +35,14 @@ pub(crate) fn head_of(path: &Path) -> IdeResult<(String, String)> { Ok((info.head.unwrap_or_else(|| commit.clone()), commit)) } +/// `HEAD`'s commit in `cwd`, `None` outside a repository or before its first commit. +pub(crate) fn head_commit(cwd: &Path) -> IdeResult> { + match discover(cwd)? { + Some(repo) => Ok(repo.head()?.1), + None => Ok(None), + } +} + /// `git worktree add -b agent/ / `. pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult { let repo = repo_at(project_root)?; @@ -111,7 +119,14 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult> { .into_iter() .map(|f| { let (added, removed) = stat(&f.path); - AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed } + AgentChange { + path: IpcPath(root.join(&f.path)), + rel_path: f.path, + kind: f.kind, + added, + removed, + old_path: f.old_path, + } }) .collect(); let tracked: std::collections::HashSet = out.iter().map(|c| c.rel_path.clone()).collect(); @@ -140,6 +155,7 @@ pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult IdeResult AgentChange { let added = std::fs::read_to_string(&*path).map_or(0, |t| t.lines().count() as u32); - AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 } + AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0, old_path: None } +} + +/// The commits from `base` (exclusive) to `head` (inclusive) in `cwd`, newest first, each +/// with the files it changed against its first parent and their line counts. +pub(crate) fn commits(cwd: &Path, base: &str, head: &str) -> IdeResult> { + if base == head { + return Ok(Vec::new()); + } + let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) }; + let root = repo.root().to_path_buf(); + let query = LogQuery { range: Some(format!("{base}..{head}")), ..LogQuery::default() }; + let mut out = Vec::new(); + for commit in repo.log(&query)? { + let parent = commit.parents.first().cloned(); + // A root commit has no parent to count lines against; its files still show. + let stats: std::collections::HashMap = match &parent { + Some(parent) => repo + .diff_numstat(parent, &commit.id, &[])? + .into_iter() + .map(|s| (s.path, (s.added, s.removed))) + .collect(), + None => std::collections::HashMap::new(), + }; + let mut files: Vec = repo + .commit_files(&commit.id)? + .into_iter() + .map(|f| { + let (added, removed) = stats.get(&f.path).copied().unwrap_or((0, 0)); + AgentChange { + path: IpcPath(root.join(&f.path)), + rel_path: f.path, + kind: f.kind, + added, + removed, + old_path: f.old_path, + } + }) + .collect(); + files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); + out.push(AgentCommit { + id: commit.id, + short_id: commit.short_id, + summary: commit.summary, + message: commit.message, + time_ms: commit.time_ms, + parent, + files, + }); + } + Ok(out) } /// Whether two paths name one file (a case-insensitive disk resolving both spellings). @@ -243,6 +309,34 @@ impl TaskBase { .collect()) } + /// The task's uncommitted changes and, for a worktree task, the commits made since it + /// started (newest first). Both read the same `HEAD`, so a commit landing between the two + /// reads cannot put a file in both lists. + pub(crate) fn grouped_changes(&self, before: &BTreeMap) -> IdeResult { + let cwd = self.cwd(); + let Some(repo) = discover(cwd)? else { return Ok(AgentChanges::default()) }; + let head = repo.head()?.1; + let uncommitted = match &head { + Some(head) => without_untouched(changes(cwd, head)?, before), + None => { + // No commits yet: every file is new, as in `changes` for a local task. + let mut all: Vec = repo + .status()? + .into_iter() + .filter(|s| s.path.is_file()) + .map(|s| added(s.path, s.rel_path)) + .collect(); + all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); + without_untouched(all, before) + } + }; + let commits = match (self, &head) { + (Self::Worktree { cwd, base }, Some(head)) => commits(cwd, base, head)?, + _ => Vec::new(), + }; + Ok(AgentChanges { uncommitted, commits }) + } + /// The revision the task's changed files go back to on rollback, and are compared with: /// the commit the task started from, or the current `HEAD` once the user committed on top /// of it, so those commits stay. `None` while the repository has no commits. @@ -598,6 +692,88 @@ mod tests { assert!(!created.path.exists()); } + #[test] + fn commits_since_the_base_are_listed_with_their_files() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + std::fs::write(repo.join("a.txt"), "one\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + + let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap(); + assert!(commits(&created.path, &created.base_commit, &created.base_commit).unwrap().is_empty()); + + std::fs::write(created.path.join("a.txt"), "one\ntwo\n").unwrap(); + git(&created.path, &["add", "."]); + git(&created.path, &["commit", "-q", "-m", "change a"]); + std::fs::write(created.path.join("b.txt"), "b\n").unwrap(); + git(&created.path, &["add", "."]); + git(&created.path, &["commit", "-q", "-m", "add b"]); + + let head = head_of(&created.path).unwrap().1; + let log = commits(&created.path, &created.base_commit, &head).unwrap(); + assert_eq!(log.iter().map(|c| c.summary.as_str()).collect::>(), ["add b", "change a"]); + assert_eq!(log[0].files.iter().map(|f| f.rel_path.as_str()).collect::>(), ["b.txt"]); + assert_eq!((log[0].files[0].added, log[0].files[0].removed), (1, 0)); + assert_eq!(log[1].files.iter().map(|f| f.rel_path.as_str()).collect::>(), ["a.txt"]); + assert_eq!((log[1].files[0].added, log[1].files[0].removed), (1, 0)); + assert!(log.iter().all(|c| c.parent.is_some() && !c.message.is_empty())); + + // Nothing is uncommitted after the commits; editing a committed file brings it back. + let at = TaskBase::Worktree { cwd: created.path.clone(), base: created.base_commit.clone() }; + let grouped = at.grouped_changes(&BTreeMap::new()).unwrap(); + assert!(grouped.uncommitted.is_empty()); + assert_eq!(grouped.commits.len(), 2); + std::fs::write(created.path.join("a.txt"), "one\ntwo\nthree\n").unwrap(); + let grouped = at.grouped_changes(&BTreeMap::new()).unwrap(); + assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::>(), ["a.txt"]); + assert_eq!(grouped.commits.len(), 2); + } + + #[test] + fn a_rename_in_a_commit_keeps_its_old_path() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + std::fs::write(repo.join("a.txt"), "one\ntwo\nthree\nfour\nfive\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + let base = head_of(&repo).unwrap().1; + + std::fs::rename(repo.join("a.txt"), repo.join("b.txt")).unwrap(); + std::fs::write(repo.join("b.txt"), "one\ntwo\nthree\nfour\nFIVE\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "rename a"]); + + let head = head_of(&repo).unwrap().1; + let log = commits(&repo, &base, &head).unwrap(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].files.len(), 1); + assert_eq!(log[0].files[0].rel_path, "b.txt"); + assert_eq!(log[0].files[0].old_path.as_deref(), Some("a.txt")); + assert_eq!(log[0].files[0].kind, ChangeKind::Renamed); + } + + #[test] + fn a_local_task_has_no_commits() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + std::fs::write(repo.join("a.txt"), "one\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap(); + + let at = TaskBase::Local { cwd: repo.clone(), start: None }; + let grouped = at.grouped_changes(&BTreeMap::new()).unwrap(); + assert!(grouped.commits.is_empty()); + assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::>(), ["a.txt"]); + } + #[test] fn a_broken_worktree_registration_still_leaves_a_deletable_folder() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index a0bc46af1..da42e0dff 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -5,9 +5,9 @@ use crate::Host; use crate::rpc::Dispatcher; use ide_agent::registry::{AgentRegistry, RegistryPaths}; use ide_agent::{ - AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, - AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpScope, McpServer, McpServerInput, - NewAgentTask, + AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry, + AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, + McpScope, McpServer, McpServerInput, NewAgentTask, }; use ide_core::error::ErrorKind; use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId}; @@ -304,16 +304,28 @@ pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult IdeResult { + host.require_trust(window)?; + host.project(window)?.agents.grouped_changes(&task).await +} + /// Content of `path` when the task started (left side of its diff). pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult> { host.require_trust(window)?; host.project(window)?.agents.base_text(&task, path.into_path_buf()).await } -/// Rollback: the task's changed files among `paths` (all without) back as they were when it started. -pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Option>) -> IdeResult<()> { +/// Rollback: the task's changed files among `paths` (all without) back as `view` compares them. +pub async fn rollback_changes( + host: &Host, + window: &str, + task: String, + paths: Option>, + view: Option, +) -> IdeResult<()> { host.require_trust(window)?; - host.project(window)?.agents.rollback_changes(&task, paths).await + host.project(window)?.agents.rollback_changes(&task, paths, view.unwrap_or_default()).await } /// Accept: copies a worktree task's changed files among `paths` (all without) into the @@ -476,9 +488,10 @@ pub(crate) fn register(d: &mut Dispatcher) { |c| async move { delete(&c.host, &c.window, c.arg("task")?, c.arg("removeWorktree")?).await }, ); d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await }); + d.add("agent_grouped_changes", |c| async move { grouped_changes(&c.host, &c.window, c.arg("task")?).await }); d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await }); d.add("agent_rollback_changes", |c| async move { - rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await + rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?, c.arg("view")?).await }); d.add("agent_accept_changes", |c| async move { accept_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index 6929b6d05..50639526b 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -3,8 +3,9 @@ use crate::state::AppState; use ide_agent::registry::AgentRegistry; use ide_agent::{ - AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, - AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpServer, McpServerInput, NewAgentTask, + AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry, + AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, + McpServer, McpServerInput, NewAgentTask, }; use ide_core::{IdeResult, IpcPath, RequestId}; use ide_host::ops::agent; @@ -370,7 +371,18 @@ pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: Str agent::changes(&state.host, window.label(), task).await } -/// Rollback: the task's changed files among `paths` (all without) back as they were when it started. +/// The task's uncommitted changes and the commits made since it started. +#[tauri::command] +#[specta::specta] +pub async fn agent_grouped_changes( + window: Window, + state: State<'_, AppState>, + task: String, +) -> IdeResult { + agent::grouped_changes(&state.host, window.label(), task).await +} + +/// Rollback: the task's changed files among `paths` (all without) back as `view` compares them. #[tauri::command] #[specta::specta] pub async fn agent_rollback_changes( @@ -378,8 +390,9 @@ pub async fn agent_rollback_changes( state: State<'_, AppState>, task: String, paths: Option>, + view: Option, ) -> IdeResult<()> { - agent::rollback_changes(&state.host, window.label(), task, paths).await + agent::rollback_changes(&state.host, window.label(), task, paths, view).await } /// Accept: copies a worktree task's changed files among `paths` (all without) into the diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 965319050..fe9e2241b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -154,6 +154,7 @@ pub fn builder() -> tauri_specta::Builder { agent::agent_rename, agent::agent_delete, agent::agent_changes, + agent::agent_grouped_changes, agent::agent_base_text, agent::agent_rollback_changes, agent::agent_accept_changes, diff --git a/src/bindings.ts b/src/bindings.ts index 14b988e34..6175d4bd1 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -214,10 +214,16 @@ export const commands = { agentRename: (task: string, title: string) => typedError(__TAURI_INVOKE("agent_rename", { task, title })), agentDelete: (task: string, removeWorktree: boolean) => typedError(__TAURI_INVOKE("agent_delete", { task, removeWorktree })), agentChanges: (task: string) => typedError(__TAURI_INVOKE("agent_changes", { task })), + /** The task's uncommitted changes and the commits made since it started. */ + agentGroupedChanges: (task: string) => typedError(__TAURI_INVOKE("agent_grouped_changes", { task })), /** Content of `path` when the task started (left side of its diff). */ agentBaseText: (task: string, path: IpcPath) => typedError(__TAURI_INVOKE("agent_base_text", { task, path })), - /** Rollback: the task's changed files among `paths` (all without) back as they were when it started. */ - agentRollbackChanges: (task: string, paths: string[] | null) => typedError(__TAURI_INVOKE("agent_rollback_changes", { task, paths })), + /** Rollback: the task's changed files among `paths` (all without) back as `view` compares them. */ + agentRollbackChanges: (task: string, paths: string[] | null, view: +/** Everything since the commit the task started from. */ +"sinceStart" | +/** The uncommitted files against `HEAD`; commits made since the start stay. */ +"group" | null) => typedError(__TAURI_INVOKE("agent_rollback_changes", { task, paths, view })), /** * Accept: copies a worktree task's changed files among `paths` (all without) into the * local checkout, uncommitted; files the checkout changed itself are left alone and reported. @@ -1737,15 +1743,38 @@ export type AgentCapabilities = { backgroundTasks?: boolean, }; -/** A file the task changed, relative to [`AgentTask::base_commit`]. */ +/** + * A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the + * grouped view. + */ export type AgentChange = { path: IpcPath, relPath: string, kind: ChangeKind, added: number, removed: number, + /** Path before a rename; the left side of its diff reads this path. */ + oldPath: string | null, }; +/** A task's uncommitted files and the commits made since it started. */ +export type AgentChanges = { + /** Files changed against `HEAD`, less those changed before the task started and untouched since. */ + uncommitted: AgentChange[], + /** + * Commits from the task's start (exclusive) to `HEAD` (inclusive), newest first. Empty + * for tasks working in the project's own checkout. + */ + commits: AgentCommit[], +}; + +/** Which comparison a rollback of the task's changes uses. */ +export type AgentChangesView = +/** Everything since the commit the task started from. */ +"sinceStart" | +/** The uncommitted files against `HEAD`; commits made since the start stay. */ +"group"; + export type AgentCommand = { /** Without the leading `/`. */ name: string, @@ -1760,6 +1789,18 @@ export type AgentCommandReadOnlyReason = "packageRunner" | "registry" | "customA export type AgentCommandSource = "command" | "skill"; +/** A commit made while the task ran, with the files it changed against its first parent. */ +export type AgentCommit = { + id: string, + shortId: string, + summary: string, + message: string, + timeMs: number | null, + /** First parent, or `None` for a root commit. */ + parent: string | null, + files: AgentChange[], +}; + /** A setting chosen for a new task, by [`AgentConfigOption::id`]. */ export type AgentConfigChoice = { id: string, diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index 1ad51522e..037b9b05d 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -1102,6 +1102,47 @@ font-variant-numeric: tabular-nums; } +/* Group and commit rows of the grouped Changes pane. */ +.changeGroup { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} +.changeGroupName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; + color: var(--fg-strong); +} +.changeCommit { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} +.changeCommitHash { + flex: none; + color: var(--fg-subtle); + font-family: var(--font-mono); +} +.changeCommitSummary { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.changeCommitTime { + flex: none; + color: var(--fg-subtle); + font-size: var(--fs-small); +} + .added { color: var(--vcs-added); } diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 37ea7b99b..e2c5416c6 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -20,6 +20,7 @@ import clsx from "clsx"; import { useShallow } from "zustand/react/shallow"; import type { AgentChange, + AgentCommit, AgentEntry, AgentImage, AgentLimit, @@ -57,7 +58,9 @@ import { } from "@/ui"; import { useTx, tx } from "@/platform/i18n"; import { isImeKey } from "@/platform/ime"; -import { openDiff, withChain, type DiffData } from "@/features/vcs/diffInput"; +import { commitFileDiffData, openDiff, withChain, type DiffData } from "@/features/vcs/diffInput"; +import { repositoryPath } from "@/features/vcs/repositoryPaths"; +import { setChangesOptions, useChangesOptions } from "./changesOptions"; import { Composer } from "./Composer"; import { selectionIn } from "./selection"; import { SubmitButton } from "./SubmitButton"; @@ -112,6 +115,7 @@ import { markVisible, refreshChanges, rollbackChanges, + scheduleChanges, editQueued, forkTask, rerunFrom, @@ -136,7 +140,13 @@ export function SessionEditor({ input, docked = false }: { input: EditorInput; d function TaskSession({ id, docked }: { id: string; docked: boolean }) { const task = useAgents((s) => s.tasks.get(id)); const entries = useAgents((s) => s.entries.get(id)); - const changes = useAgents((s) => s.changes.get(id)); + // Grouping needs commits of the task's own: local checkouts keep the flat list. + const isolation = useAgents((s) => s.tasks.get(id)?.isolation); + const grouped = useChangesOptions((s) => s.groupByCommit) && isolation === "worktree"; + const sinceStart = useAgents((s) => s.changes.get(id)); + const review = useAgents((s) => s.groupedChanges.get(id)); + const changes = grouped ? (review?.uncommitted ?? null) : (sinceStart ?? null); + const commits = grouped ? (review?.commits ?? []) : []; const project = useProject((s) => s.project); const head = useLocalHead( task?.isolation === "local" ? task.status : null, @@ -161,6 +171,11 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) { if (!useAgents.getState().entries.has(id)) void loadEntries(id).catch(() => undefined); void refreshChanges(id); }, [id, connected]); + // A commit in the task's worktree moves its HEAD: pick it up without waiting for the agent. + useEffect(() => { + if (!connected) return; + return onIdeEvent("vcsChanged", () => scheduleChanges(id)); + }, [id, connected]); // A kept-alive tab stays mounted behind the shown one: it isn't read there, nor searched. const shown = useEditorShown(); useEffect(() => { @@ -224,6 +239,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) { entries={entries ?? null} head={head} changes={changes ?? null} + commits={commits} changesOpen={changesOpen} onToggleChanges={() => setChangesOpen(!changesOpen)} onOpenSubagent={openSubagent} @@ -235,6 +251,8 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) { setChangesOpen(false)} @@ -2410,6 +2428,7 @@ function Tray({ task, entries, changes, + commits, changesOpen, onToggleChanges, children, @@ -2417,6 +2436,7 @@ function Tray({ task: AgentTask; entries: AgentEntry[] | null; changes: AgentChange[] | null; + commits: readonly AgentCommit[]; changesOpen: boolean; onToggleChanges: () => void; children: ReactNode; @@ -2434,7 +2454,7 @@ function Tray({ return (
{pending && } - {(planShown || !!changes?.length) && ( + {(planShown || !!changes?.length || !!commits.length) && (
{planShown && ( )} + {!!commits.length && ( + + )}
)} {planShown && planOpen && ( @@ -2571,6 +2597,7 @@ function SessionComposer({ entries, head, changes, + commits, changesOpen, onToggleChanges, onOpenSubagent, @@ -2579,6 +2606,7 @@ function SessionComposer({ entries: AgentEntry[] | null; head: string | null; changes: AgentChange[] | null; + commits: readonly AgentCommit[]; changesOpen: boolean; onToggleChanges: () => void; onOpenSubagent: (id: string) => void; @@ -2637,7 +2665,14 @@ function SessionComposer({ />
)} - + {task.queue.length > 0 && } @@ -2796,25 +2831,115 @@ function useChangesOpen( return [chosen ?? (changes?.length ?? 0) > 0, set]; } +const NO_SELECTION: ReadonlySet = new Set(); + +/** A row of the Changes pane: a group, a commit, or a file. */ +type ChangeRow = + | { + key: string; + type: "group"; + label: "Uncommitted" | "Committed"; + count: number; + added: number; + removed: number; + children: ChangeRow[]; + } + | { key: string; type: "commit"; commit: AgentCommit; children: ChangeRow[] } + | { key: string; type: "file"; change: AgentChange; commit?: AgentCommit }; + +function statsOf(changes: readonly AgentChange[]) { + return changes.reduce((n, c) => ({ added: n.added + c.added, removed: n.removed + c.removed }), { + added: 0, + removed: 0, + }); +} + +function changeRows( + uncommitted: readonly AgentChange[], + commits: readonly AgentCommit[], + grouped: boolean, +): ChangeRow[] { + if (!grouped) return uncommitted.map((change) => ({ key: change.path, type: "file" as const, change })); + const roots: ChangeRow[] = []; + if (uncommitted.length) { + roots.push({ + key: "group:uncommitted", + type: "group", + label: "Uncommitted", + count: uncommitted.length, + ...statsOf(uncommitted), + children: uncommitted.map((change) => ({ key: `uncommitted:${change.path}`, type: "file", change })), + }); + } + if (commits.length) { + const files = commits.flatMap((commit) => commit.files); + roots.push({ + key: "group:committed", + type: "group", + label: "Committed", + count: commits.length, + ...statsOf(files), + children: commits.map((commit) => ({ + key: `commit:${commit.id}`, + type: "commit", + commit, + children: commit.files.map((change) => ({ + key: `commit:${commit.id}:${change.path}`, + type: "file", + change, + commit, + })), + })), + }); + } + return roots; +} + function ChangesPane({ task, changes, + commits, + grouped, head, docked, onHide, }: { task: AgentTask; changes: AgentChange[] | null; + commits: readonly AgentCommit[]; + grouped: boolean; head: string | null; docked: boolean; onHide: () => void; }) { - const [selected, setSelected] = useState>(() => new Set()); + // The two views key their rows differently: a selection doesn't carry over. + const [selection, setSelection] = useState<{ grouped: boolean; keys: ReadonlySet }>(() => ({ + grouped, + keys: NO_SELECTION, + })); + const selected = selection.grouped === grouped ? selection.keys : NO_SELECTION; + const setSelected = (keys: ReadonlySet) => setSelection({ grouped, keys }); + const rows = useMemo(() => changeRows(changes ?? [], commits, grouped), [changes, commits, grouped]); + // Only the working files roll back; a commit's files are history. + const rollbackable = useMemo(() => { + const out = new Map(); + for (const root of rows) { + if (root.type === "file") { + if (!root.commit) out.set(root.key, root.change); + continue; + } + for (const child of root.children) { + if (child.type === "file" && !child.commit) out.set(child.key, child.change); + } + } + return out; + }, [rows]); + // Selection keys that no longer name a working file (a commit's file, a stale row) don't count. + const picked = [...selected].flatMap((key) => rollbackable.get(key) ?? []); const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0; const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0; const rollback = async () => { - const chosen = (changes ?? []).filter((c) => selected.has(c.path)); - const count = chosen.length || changes?.length || 0; + const count = picked.length || changes?.length || 0; if (!count) return; const ok = await confirm({ title: "Rollback Changes", @@ -2822,15 +2947,17 @@ function ChangesPane({ <>

{count === 1 - ? tx`Roll back the changes to ${basename((chosen[0] ?? changes![0]!).path)}?` + ? tx`Roll back the changes to ${basename((picked[0] ?? changes![0]!).path)}?` : tx`Roll back the changes to ${count} files?`}

- {task.isolation === "worktree" - ? tx("They go back to how they were when the task started, in the task's worktree.") - : tx( - "They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.", - )} + {grouped + ? tx("They go back to the last commit; changes made after it are lost.") + : task.isolation === "worktree" + ? tx("They go back to how they were when the task started, in the task's worktree.") + : tx( + "They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.", + )}

), @@ -2839,7 +2966,11 @@ function ChangesPane({ }); if (!ok) return; try { - await rollbackChanges(task.id, chosen.length ? chosen.map((c) => c.relPath) : null); + await rollbackChanges( + task.id, + picked.length ? picked.map((c) => c.relPath) : null, + grouped ? "group" : "sinceStart", + ); setSelected(new Set()); } catch (e) { notifications().notify({ @@ -2850,10 +2981,15 @@ function ChangesPane({ } }; const accept = async () => { - const chosen = (changes ?? []).filter((c) => selected.has(c.path)); - const count = chosen.length || changes?.length || 0; + // Accepting brings over the task's whole work, committed or not: grouping only changes the listing. + const all = + grouped && !picked.length + ? await call(commands.agentChanges(task.id)).catch(() => changes ?? []) + : (changes ?? []); + const targets = picked.length ? picked : all; + const count = targets.length; if (!count) return; - const name = basename((chosen[0] ?? changes![0]!).path); + const name = basename(targets[0]!.path); const ok = await confirm({ title: "Accept Changes", message: ( @@ -2871,7 +3007,7 @@ function ChangesPane({ }); if (!ok) return; try { - const outcome = await acceptChanges(task.id, chosen.length ? chosen.map((c) => c.relPath) : null); + const outcome = await acceptChanges(task.id, picked.length ? picked.map((c) => c.relPath) : null); setSelected(new Set()); if (outcome.skipped.length) { const skippedNames = outcome.skipped.map((s) => s.path).join(", "); @@ -2907,23 +3043,44 @@ function ChangesPane({ {task.isolation === "worktree" && ( void accept()} /> )} void rollback()} /> void refreshChanges(task.id)} /> + {task.isolation === "worktree" && ( + + showContextMenu(e, [ + { kind: "header", label: "Group By" }, + { + id: "groupByCommit", + label: "Commit", + checked: grouped, + run: () => { + setChangesOptions({ groupByCommit: !grouped }); + void refreshChanges(task.id); + }, + }, + ]) + } + /> + )} {!docked && } - {(head ?? task.base) && ( + {(grouped || head || task.base) && (
- vs {head ?? task.base} + vs {grouped ? "HEAD" : (head ?? task.base)} {changes && changes.length > 0 && ( <> {" "} @@ -2936,29 +3093,81 @@ function ChangesPane({ {changes === null ? ( ) : ( - + + key={grouped ? "grouped" : "flat"} aria-label="Changed files" - roots={changes} - getKey={(c) => c.path} - getLabel={(c) => basename(c.path)} - getChildren={() => undefined} - isExpandable={() => false} + roots={rows} + getKey={(n) => n.key} + getLabel={(n) => + n.type === "file" ? basename(n.change.path) : n.type === "commit" ? n.commit.summary : n.label + } + getChildren={(n) => (n.type === "file" ? undefined : n.children)} + isExpandable={(n) => n.type !== "file" && n.children.length > 0} + defaultExpanded={grouped ? ["group:uncommitted", "group:committed"] : undefined} + activateExpandable={(n) => n.type === "commit"} selected={selected} - onSelectionChange={(sel) => setSelected(sel)} - onActivate={(c) => void openChangeDiff(task, c.path)} - renderNode={(c) => ( - - - - {basename(c.path)} + onSelectionChange={(sel, lead) => { + if (lead?.type === "file" && !lead.commit) setSelected(sel); + }} + onActivate={(n) => { + if (n.type === "file") { + if (n.commit) void openCommitDiff(task, n.commit, n.change.path); + else if (grouped) void openUncommittedDiff(task, n.change.path); + else void openChangeDiff(task, n.change.path); + } else if (n.type === "commit") { + void openCommitDiff(task, n.commit, n.commit.files[0]?.path ?? ""); + } + }} + renderNode={(n) => { + if (n.type === "group") { + return ( + + + {n.label === "Uncommitted" ? Uncommitted : Committed} + + + {n.label === "Committed" + ? tx(n.count === 1 ? "1 commit" : "{count} commits").replace("{count}", String(n.count)) + : `${n.count} ${tx(n.count === 1 ? "file" : "files")}`} + + + {n.added > 0 && +{n.added}}{" "} + {n.removed > 0 && −{n.removed}} + + + ); + } + if (n.type === "commit") { + const stats = statsOf(n.commit.files); + return ( + + + {n.commit.shortId} + {n.commit.summary} + {n.commit.timeMs != null && ( + {formatSentAt(n.commit.timeMs)} + )} + + {stats.added > 0 && +{stats.added}}{" "} + {stats.removed > 0 && −{stats.removed}} + + + ); + } + return ( + + + + {basename(n.change.path)} + + {relativeDir(task.cwd, n.change.path)} + + {n.change.added > 0 && +{n.change.added}}{" "} + {n.change.removed > 0 && −{n.change.removed}} + - {relativeDir(task.cwd, c.path)} - - {c.added > 0 && +{c.added}}{" "} - {c.removed > 0 && −{c.removed}} - - - )} + ); + }} empty={
No changes yet @@ -2990,3 +3199,56 @@ export async function openChangeDiff(task: AgentTask, path: string) { ); await openDiff(withChain(diffs, paths.indexOf(path))); } + +/** Diff of `path` in the grouped view: `HEAD` ↔ the working file. */ +function uncommittedDiffData(task: AgentTask, change: AgentChange): DiffData { + const left: DiffData["left"] = + change.kind === "added" || change.kind === "untracked" + ? { type: "text", text: "", label: "(does not exist)", absent: true } + : { + type: "rev", + rev: "HEAD", + path: change.oldPath ? repositoryPath(task.cwd, change.oldPath) : undefined, + label: "HEAD", + }; + const right: DiffData["right"] = + change.kind === "deleted" + ? { type: "text", text: "", label: "(deleted)", absent: true } + : { type: "worktree", label: task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree" }; + return { path: change.path, left, right }; +} + +/** Registration needed before a diff reads revisions inside the task's own worktree. */ +async function ensureTaskRepo(task: AgentTask) { + // A worktree is its own repository, outside the project's: `vcs_file_at` reads it only once + // it is registered as an external log for this window. + if (task.isolation === "worktree") await call(commands.vcsOpenExternalLog(task.cwd)).catch(() => undefined); +} + +/** Opens the diff of `path` among the task's uncommitted files (grouped view). */ +async function openUncommittedDiff(task: AgentTask, path: string) { + const changes = useAgents.getState().groupedChanges.get(task.id)?.uncommitted ?? []; + const paths = changes.map((change) => change.path); + if (!paths.includes(path)) return; + await ensureTaskRepo(task); + await openDiff( + withChain( + changes.map((change) => uncommittedDiffData(task, change)), + paths.indexOf(path), + ), + ); +} + +/** Opens the diff of one commit (`parent ↔ commit`), starting at `path`. */ +async function openCommitDiff(task: AgentTask, commit: AgentCommit, path: string) { + const diffs = commit.files.map((file) => + commitFileDiffData( + { id: commit.id, shortId: commit.shortId, parents: commit.parent ? [commit.parent] : [], repo: task.cwd }, + { path: file.relPath, kind: file.kind, oldPath: file.oldPath }, + ), + ); + if (!diffs.length) return; + await ensureTaskRepo(task); + const index = commit.files.findIndex((file) => file.path === path); + await openDiff(withChain(diffs, index < 0 ? 0 : index)); +} diff --git a/src/features/agents/changesOptions.ts b/src/features/agents/changesOptions.ts new file mode 100644 index 000000000..fe31f17c5 --- /dev/null +++ b/src/features/agents/changesOptions.ts @@ -0,0 +1,36 @@ +/** Changes pane view options, persisted across sessions. */ +import { create } from "zustand"; + +export interface ChangesOptions { + /** Files grouped by the commit that changed them; otherwise the flat list since the task started. */ + groupByCommit: boolean; +} + +/** IDEA-like default: the review view. */ +export const DEFAULT_CHANGES_OPTIONS: ChangesOptions = { groupByCommit: true }; + +const KEY = "openidea.agents.changesView.v1"; + +function load(): ChangesOptions { + try { + const raw = localStorage.getItem(KEY); + const o = raw ? (JSON.parse(raw) as Record) : {}; + return { + groupByCommit: typeof o.groupByCommit === "boolean" ? o.groupByCommit : DEFAULT_CHANGES_OPTIONS.groupByCommit, + }; + } catch { + return { ...DEFAULT_CHANGES_OPTIONS }; + } +} + +export const useChangesOptions = create(() => load()); + +/** Updates the options and persists them. */ +export function setChangesOptions(patch: Partial) { + useChangesOptions.setState(patch); + try { + localStorage.setItem(KEY, JSON.stringify(useChangesOptions.getState())); + } catch { + // Storage unavailable: the options last for this session. + } +} diff --git a/src/features/agents/mermaidFollow.test.tsx b/src/features/agents/mermaidFollow.test.tsx index 556cb66d4..37387fe87 100644 --- a/src/features/agents/mermaidFollow.test.tsx +++ b/src/features/agents/mermaidFollow.test.tsx @@ -8,6 +8,7 @@ vi.mock("@/platform/ipc", async (original) => ({ commands: { agentEntries: ipc.entries, agentChanges: async () => ({ status: "ok", data: [] }), + agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }), vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }), }, })); diff --git a/src/features/agents/messageEditing.test.tsx b/src/features/agents/messageEditing.test.tsx index c3096162a..d17edc673 100644 --- a/src/features/agents/messageEditing.test.tsx +++ b/src/features/agents/messageEditing.test.tsx @@ -15,6 +15,7 @@ vi.mock("@/platform/ipc", async (original) => ({ commands: { agentEntries: async () => ({ status: "ok", data: entries }), agentChanges: async () => ({ status: "ok", data: [] }), + agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }), vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }), agentRollback: ipc.rollback, agentPrompt: ipc.prompt, diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx index 30d3c7798..ebcc450ca 100644 --- a/src/features/agents/sessionState.test.tsx +++ b/src/features/agents/sessionState.test.tsx @@ -4,11 +4,12 @@ import { registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; import { EditorShownProvider } from "@/workbench/contributions"; import { hideContextMenu, useContextMenuStore } from "@/ui"; -import type { AgentChange, AgentEntry, AgentTask } from "@/bindings"; +import type { AgentChange, AgentCommit, AgentEntry, AgentTask } from "@/bindings"; import { AgentsView } from "./AgentsView"; import { SessionEditor, conversationRows } from "./SessionEditor"; import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./model"; import { disconnect, useAgents } from "./store"; +import { useChangesOptions } from "./changesOptions"; import { taskActions } from "./taskActions"; import { replyActions } from "./replyActions"; import { focusedSession } from "./sessionFocus"; @@ -18,6 +19,8 @@ import tagStyles from "@/ui/InlineTag.module.css"; const backend = vi.hoisted(() => ({ entries: new Map(), changes: [] as AgentChange[], + grouped: { uncommitted: [] as AgentChange[], commits: [] as AgentCommit[] }, + rolledBack: [] as Array<[string, string[] | null, string | null]>, answers: [] as Array<[string, number, string | null]>, fetched: [] as string[], entryError: null as string | null, @@ -51,7 +54,11 @@ vi.mock("@/platform/ipc", async (original) => ({ return { status: "ok", data: backend.entries.get(key) ?? [] }; }, agentChanges: async () => ({ status: "ok", data: backend.changes }), - agentRollbackChanges: async () => ({ status: "ok", data: null }), + agentGroupedChanges: async () => ({ status: "ok", data: backend.grouped }), + agentRollbackChanges: async (task: string, paths: string[] | null, view: string | null) => { + backend.rolledBack.push([task, paths, view]); + return { status: "ok", data: null }; + }, agentAcceptChanges: async (task: string, paths: string[] | null) => { backend.accepted.push([task, paths]); return { status: "ok", data: backend.acceptedOutcome }; @@ -156,7 +163,11 @@ beforeEach(() => { disconnect(); localStorage.clear(); backend.changes = []; + backend.grouped = { uncommitted: [], commits: [] }; + backend.rolledBack = []; backend.external = []; + // The flat list unless a test asks for the grouped view, so counts and selections stay as before. + useChangesOptions.setState({ groupByCommit: false }); backend.imported = []; backend.externalError = null; backend.accepted = []; @@ -1200,6 +1211,7 @@ describe("session state across tasks and hosts", () => { kind: "modified", added: 8, removed: 2, + oldPath: null, }, ]; localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true })); @@ -1381,6 +1393,7 @@ describe("accept changes", () => { kind: "modified", added: 8, removed: 2, + oldPath: null, }, ]; localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true })); @@ -1416,6 +1429,7 @@ describe("accept changes", () => { kind: "modified", added: 8, removed: 2, + oldPath: null, }, ]; localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true })); @@ -1425,3 +1439,80 @@ describe("accept changes", () => { expect(screen.getByRole("button", { name: "Rollback All Changes…" })).toBeInTheDocument(); }); }); + +describe("grouped changes", () => { + const commit = (overrides: Partial = {}): AgentCommit => ({ + id: "a".repeat(40), + shortId: "aaaaaaa", + summary: "change a", + message: "change a\n\nbody", + timeMs: 1, + parent: "b".repeat(40), + files: [], + ...overrides, + }); + const change = (path: string): AgentChange => ({ + path: `/project/${path}`, + relPath: path, + kind: "modified", + added: 1, + removed: 1, + oldPath: null, + }); + const worktreeTask = () => { + useChangesOptions.setState({ groupByCommit: true }); + useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree" }]]) }); + localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true })); + }; + + it("puts committed files under their commit and rolls back only the uncommitted ones", async () => { + backend.grouped = { uncommitted: [change("a.ts")], commits: [commit({ files: [change("b.ts")] })] }; + worktreeTask(); + render(); + + expect(await screen.findByText("Uncommitted")).toBeInTheDocument(); + expect(screen.getByText("Committed")).toBeInTheDocument(); + expect(screen.getByText("aaaaaaa")).toBeInTheDocument(); + expect(screen.getByText("change a")).toBeInTheDocument(); + + // The commit's files show once it is expanded; selecting one is not a rollback selection. + const commitRow = screen.getByText("change a").closest("[data-row]")!; + fireEvent.mouseDown(commitRow.querySelector("[data-tree-toggle]")!); + expect(await screen.findByText("b.ts")).toBeInTheDocument(); + fireEvent.mouseDown(screen.getByText("b.ts")); + expect(screen.getByRole("button", { name: "Rollback All Changes…" })).toBeInTheDocument(); + + fireEvent.mouseDown(screen.getByText("a.ts")); + expect(screen.getByRole("button", { name: "Rollback Selected Changes…" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Rollback Selected Changes…" })); + await waitFor(() => expect(backend.confirmed).toEqual(["Rollback Changes"])); + expect(backend.rolledBack).toEqual([["A", ["a.ts"], "group"]]); + }); + + it("counts only uncommitted files in the header and the tray", async () => { + backend.grouped = { + uncommitted: [change("a.ts")], + commits: [commit({ files: [change("b.ts"), change("c.ts")] })], + }; + worktreeTask(); + render(); + await screen.findByRole("complementary", { name: "Changes" }); + expect(screen.getByRole("button", { name: /1 changed file/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /1 commit/ })).toBeInTheDocument(); + }); + + it("accepts the task's committed work too, not only the uncommitted files", async () => { + backend.grouped = { uncommitted: [], commits: [commit({ files: [change("b.ts")] })] }; + backend.changes = [change("b.ts")]; + worktreeTask(); + useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree", status: "ready" }]]) }); + render(); + await screen.findByText("Committed"); + + const accept = screen.getByRole("button", { name: "Accept All Changes…" }); + expect(accept).toBeEnabled(); + fireEvent.click(accept); + await waitFor(() => expect(backend.accepted).toEqual([["A", null]])); + }); +}); diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index 53fee4ca0..83807d87f 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -5,6 +5,8 @@ import { create } from "zustand"; import type { AgentAcceptOutcome, AgentChange, + AgentChanges, + AgentChangesView, AgentCommand, AgentConfigOption, AgentEntry, @@ -16,6 +18,7 @@ import type { ExternalSession, Isolation, } from "@/bindings"; +import { useChangesOptions } from "./changesOptions"; import { basename, useProject } from "@/platform/project"; import { defaultTaskProject } from "./project"; import { call, commands, isTauri } from "@/platform/ipc"; @@ -77,6 +80,8 @@ interface AgentsState { entries: Map; entryLoads: Map; changes: Map; + /** The task's uncommitted changes and commits, when the Changes pane groups by commit. */ + groupedChanges: Map; /** Scroll position and follow-output preference by conversation, shared by both hosts. */ scrollPositions: Map; providers: AgentProvider[]; @@ -147,6 +152,7 @@ export const useAgents = create(() => ({ entries: new Map(), entryLoads: new Map(), changes: new Map(), + groupedChanges: new Map(), providers: [], limits: new Map(), draft: savedDraft(load>(DRAFT_KEY, {})), @@ -312,6 +318,7 @@ export async function connect(): Promise { entries: new Map(), entryLoads: new Map(), changes: new Map(), + groupedChanges: new Map(), providers, }); notifyTabTitlesChanged(); @@ -372,6 +379,7 @@ export function disconnect() { entries: new Map(), entryLoads: new Map(), changes: new Map(), + groupedChanges: new Map(), docked: null, lastTask: null, homeOpen: false, @@ -522,6 +530,23 @@ export async function loadEntries(task: string, subagent: string | null = null): } export async function refreshChanges(task: string): Promise { + // Local tasks have no commits of their own to group: the flat list is their only view. + const grouped = + useChangesOptions.getState().groupByCommit && useAgents.getState().tasks.get(task)?.isolation === "worktree"; + if (grouped) { + try { + const review = await call(commands.agentGroupedChanges(task)); + useAgents.setState((s) => ({ + groupedChanges: new Map(s.groupedChanges).set(task, review), + })); + } catch { + // The worktree is gone or not a repository: nothing to list. + useAgents.setState((s) => ({ + groupedChanges: new Map(s.groupedChanges).set(task, { uncommitted: [], commits: [] }), + })); + } + return; + } try { const changes = await call(commands.agentChanges(task)); useAgents.setState((s) => ({ @@ -533,10 +558,14 @@ export async function refreshChanges(task: string): Promise { } } -/** Rollback: the task's changed files `relPaths` (all with `null`) back as they were when it started. */ -export async function rollbackChanges(task: string, relPaths: string[] | null): Promise { +/** Rollback: the task's changed files `relPaths` (all with `null`) back as `view` compares them. */ +export async function rollbackChanges( + task: string, + relPaths: string[] | null, + view: AgentChangesView = "sinceStart", +): Promise { try { - await call(commands.agentRollbackChanges(task, relPaths)); + await call(commands.agentRollbackChanges(task, relPaths, view)); } finally { await refreshChanges(task); } @@ -552,7 +581,8 @@ export async function acceptChanges(task: string, relPaths: string[] | null): Pr } const pendingChanges = new Map>(); -function scheduleChanges(task: string) { +/** Reloads the task's changes after a short pause (tool diffs, commits landing). */ +export function scheduleChanges(task: string) { if (pendingChanges.has(task)) return; pendingChanges.set( task, diff --git a/src/features/agents/unread.test.tsx b/src/features/agents/unread.test.tsx index 333c8d439..3132c34aa 100644 --- a/src/features/agents/unread.test.tsx +++ b/src/features/agents/unread.test.tsx @@ -55,6 +55,7 @@ vi.mock("@/platform/ipc", async (original) => ({ }, agentProviders: async () => ({ status: "ok", data: [] }), agentChanges: async () => ({ status: "ok", data: [] }), + agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }), agentBaseText: async (_task: string, path: string) => ({ status: "ok", data: `base of ${path}` }), agentEntries: async () => ({ status: "ok", data: await backend.entries }), }, @@ -413,6 +414,7 @@ describe("the diff of an agent change", () => { kind: "modified" as const, added: 1, removed: 1, + oldPath: null, }); it("lists the task's other changed files, so F7 moves on to the next one", async () => { diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index f69d76c14..f05dff969 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -138,6 +138,7 @@ "Roll back the changes to {count} files?": "Roll back the changes to {count} files?", "They go back to how they were when the task started, in the task's worktree.": "They go back to how they were when the task started, in the task's worktree.", "They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.": "They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.", + "They go back to the last commit; changes made after it are lost.": "They go back to the last commit; changes made after it are lost.", "Cannot roll back the changes": "Cannot roll back the changes", "Cannot connect": "Cannot connect", "Rollback Selected Changes…": "Rollback Selected Changes…", @@ -1359,5 +1360,10 @@ "Closes the agent task tab in the editor; the task keeps running in the Agents tool window.": "Closes the agent task tab in the editor; the task keeps running in the Agents tool window.", "Closes the agent task tab in the editor, then opens the New Task page.": "Closes the agent task tab in the editor, then opens the New Task page.", "May be incomplete: plugin indexes are still updating": "May be incomplete: plugin indexes are still updating", - "Plugin indexes could not be updated; see the plugin notification": "Plugin indexes could not be updated; see the plugin notification" + "Plugin indexes could not be updated; see the plugin notification": "Plugin indexes could not be updated; see the plugin notification", + "Uncommitted": "Uncommitted", + "Committed": "Committed", + "Working tree": "Working tree", + "1 commit": "1 commit", + "{count} commits": "{count} commits" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5e316cebc..66212c5e3 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -138,6 +138,7 @@ "Roll back the changes to {count} files?": "是否回滚对 {count} 个文件的更改?", "They go back to how they were when the task started, in the task's worktree.": "它们会在任务的工作树中恢复为任务开始时的内容。", "They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.": "它们会恢复为任务开始时的内容。任务开始后你对它们做的修改也会丢失;任务开始前的修改会保留。", + "They go back to the last commit; changes made after it are lost.": "它们会回到最后一次提交的状态;之后的改动会丢失。", "Cannot roll back the changes": "无法回滚更改", "Copy the changes to {name} into the local checkout?": "是否将对 {name} 的更改复制到本地检出?", "Copy the changes to {count} files into the local checkout?": "是否将对 {count} 个文件的更改复制到本地检出?", @@ -868,6 +869,7 @@ "Commit message": "提交消息", "Commit message is empty": "提交消息为空", "Commit:": "提交:", + "Committed": "已提交", "Commits": "提交", "Commits are applied from top to bottom.": "提交将从上到下依次应用。", "Commits to rebase": "要变基的提交", @@ -2871,6 +2873,7 @@ "Uninstalled {name}": "已卸载 {name}", "URL:": "URL:", "The controls show the scheme's actual appearance; changes override it.": "控件显示方案的实际外观;修改即覆盖方案。", + "Uncommitted": "未提交", "Uncommitted changes are stashed before the update and restored afterwards.": "更新前会储藏未提交的更改,并在之后恢复。", "Underline": "下划线", "Underscored": "下划线", @@ -2992,6 +2995,7 @@ "Words": "单词", "Working": "正在工作", "Working directory": "工作目录", + "Working tree": "工作树", "Wrap when typing reaches right margin": "输入到达右边距时换行", "Wrapping": "换行", "Yellow": "黄色",