fix(agents): restore archived tasks for continued conversation (#349)

Archived agent tasks could be opened but never returned to the active list, so their message composer stayed disabled. Unarchive is now available in the task header, in the archived-task notice above the composer, and in the task context menu (including mixed bulk selections). Restoring a task moves it back to the active list, keeps its conversation and worktree, and reconnects the existing agent session on the next message; the backend persists and broadcasts the restored status and refuses restoration while a stopped turn is still settling.
This commit is contained in:
l0ng-ai
2026-10-30 13:55:26 +08:00
committed by GitHub
parent 2203938491
commit 9a5b44bebb
11 changed files with 132 additions and 3 deletions
+20
View File
@@ -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)?;
+33
View File
@@ -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();
}
+5
View File
@@ -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",
+6
View File
@@ -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(
+1
View File
@@ -94,6 +94,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
agent::agent_cancel,
agent::agent_respond,
agent::agent_archive,
agent::agent_unarchive,
agent::agent_delete,
agent::agent_changes,
agent::agent_base_text,
+1
View File
@@ -112,6 +112,7 @@ export const commands = {
/** Answers a permission request; `option: null` cancels it. */
agentRespond: (task: string, permission: number, option: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_respond", { task, permission, option })),
agentArchive: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_archive", { task })),
agentUnarchive: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_unarchive", { task })),
agentDelete: (task: string, removeWorktree: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_delete", { task, removeWorktree })),
agentChanges: (task: string) => typedError<AgentChange[], IdeError>(__TAURI_INVOKE("agent_changes", { task })),
/** Content of `path` when the task started (left side of its diff). */
+15 -1
View File
@@ -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<typeof import("./store")>()),
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"] });
+19
View File
@@ -216,6 +216,15 @@ function Header({ task, entries, head }: { task: AgentTask; entries: AgentEntry[
}
/>
)}
{task.status === "archived" && (
<IconButton
icon="undo"
title="Unarchive Task"
size="small"
action="agents.unarchiveTasks"
actionArg={[task.id]}
/>
)}
{!busy && task.status !== "archived" && (
<IconButton icon="close" title="Archive Task" size="small" onClick={() => void archiveTask(task.id)} />
)}
@@ -1064,6 +1073,16 @@ function SessionComposer({
);
return (
<div className={styles.composerArea}>
{task.status === "archived" && (
<div className={styles.banner}>
<span className={styles.bannerText}>
<T>This task is archived. Unarchive it to continue the conversation.</T>
</span>
<Button size="small" onClick={() => void executeAction("agents.unarchiveTasks", [task.id])}>
Unarchive
</Button>
</div>
)}
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
{task.queue.length > 0 && <Queue task={task} />}
<Composer
+1
View File
@@ -489,5 +489,6 @@ export function addReferences(refs: Array<{ path: string; rel: string; mention:
export const cancelTask = (task: string, subagent: string | null = null) => 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));
+24 -1
View File
@@ -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",
+7 -1
View File
@@ -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})"
}