diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 464bbc04..e9b91f52 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -760,6 +760,26 @@ impl AgentManager { Ok(()) } + /// Returns an archived task to the active list without starting the agent. + pub fn unarchive(&self, id: &str) -> IdeResult<()> { + let task = self.task(id)?; + { + let mut s = task.state.lock(); + if s.task.status != AgentStatus::Archived { + return Ok(()); + } + if s.busy { + return Err(IdeError::invalid("the task is still stopping")); + } + s.task.status = AgentStatus::Ready; + s.task.error = None; + s.task.updated_ms = now_ms(); + } + task.persist(); + task.emit_task(); + Ok(()) + } + /// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays). pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> { let task = self.task(id)?; diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 0acf9ffc..21215f84 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -817,3 +817,36 @@ async fn cursor_approval_choice_applies_before_first_prompt() { wait_for(&mgr, &task.id, AgentStatus::Ready).await; mgr.shutdown(); } + +#[tokio::test(flavor = "multi_thread")] +async fn archived_tasks_can_be_restored_and_continue_their_session() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + prompt: text("again"), + provider: "claude".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let entries = mgr.entries(&task.id, None).unwrap(); + mgr.archive(&task.id).unwrap(); + assert!(mgr.prompt(&task.id, text("again"), false).await.is_err()); + mgr.unarchive(&task.id).unwrap(); + assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready); + assert!(mgr.task(&task.id).unwrap().live.lock().is_none()); + assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len()); + drop(mgr); + let mgr = manager(tmp.path(), agent.clone()); + assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready); + mgr.prompt(&task.id, text("again"), false).await.unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + assert!(agent.calls.lock().iter().any(|call| call == "session/load")); + assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len() + 2); + mgr.unarchive(&task.id).unwrap(); +} diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index a3b9ae87..8aef1efa 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -140,6 +140,10 @@ pub fn archive(host: &Host, window: &str, task: String) -> IdeResult<()> { host.project(window)?.agents.archive(&task) } +pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> { + host.project(window)?.agents.unarchive(&task) +} + pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> { host.project(window)?.agents.delete(&task, remove_worktree).await } @@ -183,6 +187,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("agent_respond", |c| async move { respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?) }); + d.add("agent_unarchive", |c| async move { unarchive(&c.host, &c.window, c.arg("task")?) }); d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) }); d.add( "agent_delete", diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index ae2d6cfe..21b699c9 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -218,6 +218,12 @@ pub fn agent_archive(window: Window, state: State<'_, AppState>, task: String) - agent::archive(&state.host, window.label(), task) } +#[tauri::command] +#[specta::specta] +pub fn agent_unarchive(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> { + agent::unarchive(&state.host, window.label(), task) +} + #[tauri::command] #[specta::specta] pub async fn agent_delete( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index fe9264ef..71c00ca7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -94,6 +94,7 @@ pub fn builder() -> tauri_specta::Builder { agent::agent_cancel, agent::agent_respond, agent::agent_archive, + agent::agent_unarchive, agent::agent_delete, agent::agent_changes, agent::agent_base_text, diff --git a/src/bindings.ts b/src/bindings.ts index a1819fd4..5c2a2d97 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -112,6 +112,7 @@ export const commands = { /** Answers a permission request; `option: null` cancels it. */ agentRespond: (task: string, permission: number, option: string | null) => typedError(__TAURI_INVOKE("agent_respond", { task, permission, option })), agentArchive: (task: string) => typedError(__TAURI_INVOKE("agent_archive", { task })), + agentUnarchive: (task: string) => typedError(__TAURI_INVOKE("agent_unarchive", { task })), agentDelete: (task: string, removeWorktree: boolean) => typedError(__TAURI_INVOKE("agent_delete", { task, removeWorktree })), agentChanges: (task: string) => typedError(__TAURI_INVOKE("agent_changes", { task })), /** Content of `path` when the task started (left side of its diff). */ diff --git a/src/features/agents/AgentsView.test.tsx b/src/features/agents/AgentsView.test.tsx index 8d831a98..9929b0c8 100644 --- a/src/features/agents/AgentsView.test.tsx +++ b/src/features/agents/AgentsView.test.tsx @@ -7,7 +7,7 @@ import { confirmChecked, showError } from "@/workbench/messages"; import { ContextMenuHost, hideContextMenu } from "@/ui"; import { AgentsView } from "./AgentsView"; import { SESSION_KIND } from "./model"; -import { archiveTask, deleteTask, openTask, useAgents } from "./store"; +import { archiveTask, unarchiveTask, deleteTask, openTask, useAgents } from "./store"; import { taskActions } from "./taskActions"; const editor = vi.hoisted(() => ({ @@ -32,6 +32,7 @@ vi.mock("./store", async (original) => ({ ...(await original()), openTask: vi.fn((id: string) => editor.listener?.({ kind: "agent-session", key: id, data: { task: id } })), archiveTask: vi.fn(async () => {}), + unarchiveTask: vi.fn(async () => {}), deleteTask: vi.fn(async () => {}), })); @@ -183,6 +184,19 @@ describe("Agents bulk actions", () => { await waitFor(() => expect(archiveTask).toHaveBeenCalledExactlyOnceWith("two")); }); + it("unarchives only archived tasks in a mixed selection", async () => { + const tasks = [task("one"), task("two", "archived"), task("three", "archived")]; + useAgents.setState({ tasks: new Map(tasks.map((t) => [t.id, t])) }); + setup(); + fireEvent.doubleClick(screen.getByText("Archived")); + select("one"); + select("three", { shiftKey: true }); + context("two"); + fireEvent.click(screen.getByRole("menuitem", { name: "Unarchive (2)" })); + await waitFor(() => expect(unarchiveTask).toHaveBeenCalledTimes(2)); + expect(vi.mocked(unarchiveTask).mock.calls).toEqual([["two"], ["three"]]); + }); + it("cancels the whole batch", async () => { vi.mocked(confirmChecked).mockResolvedValue(null); await taskActions.find((action) => action.id === "agents.deleteTasks")!.run({ ctx: {}, arg: ["one", "two"] }); diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index d335026b..71753dac 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -216,6 +216,15 @@ function Header({ task, entries, head }: { task: AgentTask; entries: AgentEntry[ } /> )} + {task.status === "archived" && ( + + )} {!busy && task.status !== "archived" && ( void archiveTask(task.id)} /> )} @@ -1064,6 +1073,16 @@ function SessionComposer({ ); return (
+ {task.status === "archived" && ( +
+ + This task is archived. Unarchive it to continue the conversation. + + +
+ )} {task.queue.length > 0 && } call(commands.agentCancel(task, subagent)); export const respond = (task: string, permission: number, option: string | null) => call(commands.agentRespond(task, permission, option)); +export const unarchiveTask = (task: string) => call(commands.agentUnarchive(task)); export const archiveTask = (task: string) => call(commands.agentArchive(task)); export const deleteTask = (task: string, removeWorktree: boolean) => call(commands.agentDelete(task, removeWorktree)); diff --git a/src/features/agents/taskActions.ts b/src/features/agents/taskActions.ts index c7c63b11..7cac1760 100644 --- a/src/features/agents/taskActions.ts +++ b/src/features/agents/taskActions.ts @@ -4,7 +4,7 @@ import { tx } from "@/platform/i18n"; import type { MenuItem } from "@/ui"; import { showError, confirmChecked } from "@/workbench/messages"; import { isBusy } from "./model"; -import { archiveTask, deleteTask, openTask, useAgents } from "./store"; +import { archiveTask, unarchiveTask, deleteTask, openTask, useAgents } from "./store"; const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived"; @@ -75,6 +75,18 @@ export const taskActions: Action[] = [ run: ({ arg }) => runTasks(resolveTasks(arg).filter(canArchive), (task) => archiveTask(task.id), tx("Cannot archive agent tasks")), }, + { + id: "agents.unarchiveTasks", + title: "Unarchive Agent Tasks", + category: "Agents", + hidden: true, + run: ({ arg }) => + runTasks( + resolveTasks(arg).filter((task) => task.status === "archived"), + (task) => unarchiveTask(task.id), + tx("Cannot unarchive agent tasks"), + ), + }, { id: "agents.deleteTasks", title: "Delete Agent Tasks", @@ -89,6 +101,7 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] { const ids = tasks.map((task) => task.id); const task = tasks[0]!; const multiple = tasks.length > 1; + const archived = tasks.filter((task) => task.status === "archived").length; const archivable = tasks.filter(canArchive).length; return [ ...(!multiple @@ -98,6 +111,16 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] { { kind: "separator" }, ] satisfies MenuItem[]) : []), + ...(archived + ? ([ + { + kind: "action", + action: "agents.unarchiveTasks", + label: multiple ? tx`Unarchive (${archived})` : "Unarchive", + arg: ids, + }, + ] satisfies MenuItem[]) + : []), { kind: "action", action: "agents.archiveTasks", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index df54ae26..b9cd1351 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4899,5 +4899,11 @@ "Credentials the IDE answers git's HTTPS prompts with, stored in the app's own secret file (not git's credential.helper). A rejected credential is forgotten automatically.": "IDE 用于应答 git HTTPS 提示的凭据,存储在应用自己的密钥文件中(而非 git 的 credential.helper)。被拒绝的凭据会自动删除。", "Saved git credentials": "已保存的 git 凭据", "Forget": "忘记", - "Remember for this host": "记住此主机" + "Remember for this host": "记住此主机", + "Unarchive Task": "取消归档任务", + "This task is archived. Unarchive it to continue the conversation.": "此任务已归档。取消归档后即可继续对话。", + "Unarchive": "取消归档", + "Unarchive Agent Tasks": "取消归档 Agent 任务", + "Cannot unarchive agent tasks": "无法取消归档 Agent 任务", + "Unarchive ({archived})": "取消归档({archived})" }