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
+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})"
}