fix(agents): preserve Shift selection and add bulk task actions (#283)

This commit is contained in:
l0ng-ai
2026-10-29 20:53:02 +08:00
committed by GitHub
parent b1347dd331
commit aac924517c
6 changed files with 352 additions and 36 deletions
+209
View File
@@ -0,0 +1,209 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { AgentTask } from "@/bindings";
import { registerActions } from "@/platform/actions";
import type { EditorInput } from "@/workbench/contributions";
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 { taskActions } from "./taskActions";
const editor = vi.hoisted(() => ({
active: undefined as EditorInput | undefined,
listener: undefined as ((input: EditorInput) => void) | undefined,
}));
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
maybeEditorService: () => ({
activeInput: () => editor.active,
onActiveInputChange: (listener: typeof editor.listener) => {
editor.listener = listener;
return () => {
editor.listener = undefined;
};
},
}),
}));
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 () => {}),
deleteTask: vi.fn(async () => {}),
}));
vi.mock("@/workbench/messages", () => ({
confirmChecked: vi.fn(async () => ({ checked: false })),
showError: vi.fn(async () => {}),
}));
function task(
id: string,
status: AgentTask["status"] = "ready",
isolation: AgentTask["isolation"] = "local",
): AgentTask {
return {
id,
title: id,
status,
isolation,
provider: "codex",
model: null,
cwd: "/project",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
};
}
const row = (id: string) => screen.getByText(id).closest('[role="treeitem"]')!;
const select = (id: string, modifiers: MouseEventInit = {}) =>
fireEvent.mouseDown(row(id), { button: 0, ...modifiers });
const selectedTasks = () =>
screen
.queryAllByRole("treeitem", { selected: true })
.filter((item) => item.getAttribute("aria-level") === "2")
.map((item) => item.querySelector("[title]")?.getAttribute("title"));
const context = (id: string) => {
fireEvent.mouseDown(row(id), { button: 2 });
fireEvent.contextMenu(row(id));
};
let dispose: () => void;
beforeEach(() => {
vi.clearAllMocks();
editor.active = undefined;
vi.mocked(confirmChecked).mockResolvedValue({ checked: false });
useAgents.setState({
loaded: true,
error: null,
tasks: new Map([task("one"), task("two"), task("three"), task("four")].map((t) => [t.id, t])),
});
dispose = registerActions(taskActions);
});
afterEach(() => {
dispose();
hideContextMenu();
});
function setup() {
render(
<>
<AgentsView />
<ContextMenuHost />
</>,
);
}
describe("Agents selection", () => {
it("extends and shrinks a range without opening its lead or losing selection to an editor echo", () => {
setup();
select("one");
select("three", { shiftKey: true });
expect(selectedTasks()).toEqual(["one", "two", "three"]);
expect(openTask).toHaveBeenCalledTimes(1);
act(() => editor.listener?.({ kind: SESSION_KIND, key: "one", data: { task: "one" } }));
expect(selectedTasks()).toEqual(["one", "two", "three"]);
select("two", { shiftKey: true });
expect(selectedTasks()).toEqual(["one", "two"]);
select("four");
expect(selectedTasks()).toEqual(["four"]);
});
it("keeps the initial active task as the anchor for repeated keyboard extension", () => {
editor.active = { kind: SESSION_KIND, key: "one", data: { task: "one" } };
setup();
const tree = screen.getByRole("tree");
fireEvent.keyDown(tree, { key: "ArrowDown", shiftKey: true });
fireEvent.keyDown(tree, { key: "ArrowDown", shiftKey: true });
expect(selectedTasks()).toEqual(["one", "two", "three"]);
expect(openTask).not.toHaveBeenCalled();
});
it.each(["metaKey", "ctrlKey"])("toggles individual tasks with %s", (modifier) => {
setup();
select("one");
select("three", { [modifier]: true });
expect(selectedTasks()).toEqual(["one", "three"]);
select("three", { [modifier]: true });
expect(selectedTasks()).toEqual(["one"]);
expect(openTask).toHaveBeenCalledTimes(1);
});
});
describe("Agents bulk actions", () => {
it("deletes the right-clicked selection after one confirmation, excluding group headers", async () => {
const tasks = [task("one", "running"), task("two"), task("three")];
useAgents.setState({ tasks: new Map(tasks.map((t) => [t.id, t])) });
setup();
select("one");
select("three", { shiftKey: true });
context("two");
fireEvent.click(screen.getByRole("menuitem", { name: "Delete (3)…" }));
await waitFor(() => expect(deleteTask).toHaveBeenCalledTimes(3));
expect(confirmChecked).toHaveBeenCalledTimes(1);
expect(confirmChecked).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("3 agent tasks") }),
);
expect(vi.mocked(deleteTask).mock.calls).toEqual([
["one", false],
["two", false],
["three", false],
]);
});
it("right-clicking outside the selection targets only that task", async () => {
setup();
select("one");
select("two", { shiftKey: true });
context("four");
fireEvent.click(screen.getByRole("menuitem", { name: "Delete…" }));
await waitFor(() => expect(deleteTask).toHaveBeenCalledExactlyOnceWith("four", false));
});
it("archives only eligible tasks in a mixed selection", async () => {
const tasks = [task("one", "running"), task("two"), 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: "Archive (1)" }));
await waitFor(() => expect(archiveTask).toHaveBeenCalledExactlyOnceWith("two"));
});
it("cancels the whole batch", async () => {
vi.mocked(confirmChecked).mockResolvedValue(null);
await taskActions.find((action) => action.id === "agents.deleteTasks")!.run({ ctx: {}, arg: ["one", "two"] });
expect(deleteTask).not.toHaveBeenCalled();
});
it.each([false, true])("applies worktree cleanup (%s) only to worktree tasks", async (checked) => {
const tasks = [task("one"), task("two", "ready", "worktree")];
useAgents.setState({ tasks: new Map(tasks.map((t) => [t.id, t])) });
vi.mocked(confirmChecked).mockResolvedValue({ checked });
await taskActions.find((action) => action.id === "agents.deleteTasks")!.run({ ctx: {}, arg: ["one", "two"] });
expect(vi.mocked(deleteTask).mock.calls).toEqual([
["one", false],
["two", checked],
]);
});
it("continues after an error and reports the failed task", async () => {
vi.mocked(deleteTask).mockRejectedValueOnce(new Error("Cannot remove worktree"));
await taskActions.find((action) => action.id === "agents.deleteTasks")!.run({ ctx: {}, arg: ["one", "two"] });
expect(deleteTask).toHaveBeenCalledTimes(2);
expect(showError).toHaveBeenCalledWith("Cannot delete agent tasks", "one: Cannot remove worktree");
});
});
+15 -35
View File
@@ -1,14 +1,13 @@
/** Agents tool window: tasks grouped by what they need from the user. */
import { useEffect, useMemo, useState } from "react";
import type { AgentStatus as Status, AgentTask } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { maybeEditorService, type EditorInput } from "@/workbench/contributions";
import { confirmChecked } from "@/workbench/messages";
import { EmptyState, Icon, IconButton, Spinner, T, Tree, showContextMenu, type MenuItem } from "@/ui";
import { SESSION_KIND, age, branchLabel, groupTasks, isBusy, type TaskGroup } from "./model";
import { archiveTask, deleteTask, openHome, openTask, useAgents } from "./store";
import { EmptyState, Icon, IconButton, Spinner, T, Tree, showContextMenu } from "@/ui";
import { SESSION_KIND, age, branchLabel, groupTasks, type TaskGroup } from "./model";
import { openHome, openTask, useAgents } from "./store";
import { taskMenu } from "./taskActions";
import styles from "./Agents.module.css";
import { tx, useTx } from "@/platform/i18n";
import { useTx } from "@/platform/i18n";
export function AgentsHeaderActions() {
return (
@@ -61,7 +60,7 @@ export function AgentsView() {
maybeEditorService()?.onActiveInputChange?.((input) => {
const task = sessionTask(input);
if (!task) return;
setSelected(new Set([task]));
setSelected((current) => (current.has(task) ? current : new Set([task])));
const group = groupTasks(useAgents.getState().tasks.values()).find((g) => g.tasks.some((t) => t.id === task));
const key = group && `g:${group.status}`;
if (key) setCollapsed((c) => (c.has(key) ? new Set([...c].filter((k) => k !== key)) : c));
@@ -102,10 +101,17 @@ export function AgentsView() {
selected={selected}
onSelectionChange={(sel, lead) => {
setSelected(sel);
if (lead?.kind === "task") openTask(lead.task.id);
if (sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) openTask(lead.task.id);
}}
onActivate={(n) => n.kind === "task" && openTask(n.task.id)}
onContextMenu={(n, _sel, e) => n?.kind === "task" && showContextMenu(e, taskMenu(n.task))}
onContextMenu={(n, sel, e) => {
if (n?.kind !== "task") return;
const selection = [...sel].flatMap((id) => {
const task = tasks.get(id);
return task ? [task] : [];
});
showContextMenu(e, taskMenu(selection));
}}
renderNode={(n) =>
n.kind === "group" ? (
<span className={styles.row}>
@@ -143,29 +149,3 @@ export function AgentsView() {
</div>
);
}
function taskMenu(task: AgentTask): MenuItem[] {
const busy = isBusy(task.status);
return [
{ label: "Open", run: () => openTask(task.id) },
{ label: "Open in Terminal", icon: "terminal", run: () => void executeAction("terminal.openAt", task.cwd) },
{ kind: "separator" },
{ label: "Archive", disabled: busy || task.status === "archived", run: () => void archiveTask(task.id) },
{ label: "Delete…", run: () => void confirmDelete(task) },
];
}
async function confirmDelete(task: AgentTask) {
const worktree = task.isolation === "worktree";
const r = await confirmChecked({
title: "Delete Agent Task",
message: worktree
? tx`Delete “${task.title}”? Its branch ${task.branch ?? ""} is kept.`
: tx`Delete “${task.title}”? Files it changed are kept.`,
ok: "Delete",
icon: "warning",
checkbox: worktree ? "Also remove its worktree folder" : undefined,
checked: worktree,
});
if (r) await deleteTask(task.id, worktree && r.checked);
}
+2
View File
@@ -21,6 +21,7 @@ import { HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TOOL_WINDOW } from "./model";
import { sendPaths, sendSelection } from "./references";
import { focusedSession } from "./sessionFocus";
import { connect, disconnect, openHome, useAgents } from "./store";
import { taskActions } from "./taskActions";
/** Settings › Tools › Agents (also used by the Settings window). */
export function activateSettingsPages(): () => void {
@@ -89,6 +90,7 @@ export function activate(): () => void {
: [],
}),
registerActions([
...taskActions,
{
id: "agents.findInConversation",
title: "Find in Conversation",
+115
View File
@@ -0,0 +1,115 @@
import type { AgentTask } from "@/bindings";
import type { Action } from "@/platform/actions";
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";
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
function resolveTasks(arg: unknown): AgentTask[] {
if (!Array.isArray(arg)) return [];
return [...new Set(arg)].flatMap((id: unknown) => {
const task = typeof id === "string" ? useAgents.getState().tasks.get(id) : undefined;
return task ? [task] : [];
});
}
async function runTasks(tasks: AgentTask[], run: (task: AgentTask) => Promise<unknown>, title: string) {
const errors: string[] = [];
for (const task of tasks) {
try {
await run(task);
} catch (error) {
errors.push(`${task.title}: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (errors.length) await showError(title, errors.join("\n"));
}
async function confirmDelete(tasks: AgentTask[]) {
if (!tasks.length) return;
const task = tasks[0]!;
const multiple = tasks.length > 1;
const worktree = tasks.some((t) => t.isolation === "worktree");
const r = await confirmChecked({
title: multiple ? "Delete Agent Tasks" : "Delete Agent Task",
message: multiple
? tx`Delete ${tasks.length} agent tasks? Their branches and files outside worktrees are kept.`
: worktree
? tx`Delete “${task.title}”? Its branch ${task.branch ?? ""} is kept.`
: tx`Delete “${task.title}”? Files it changed are kept.`,
ok: "Delete",
icon: "warning",
checkbox: worktree
? multiple
? "Also remove their worktree folders"
: "Also remove its worktree folder"
: undefined,
checked: worktree,
});
if (r)
await runTasks(
tasks,
(t) => deleteTask(t.id, t.isolation === "worktree" && r.checked),
tx("Cannot delete agent tasks"),
);
}
export const taskActions: Action[] = [
{
id: "agents.openTask",
title: "Open Agent Task",
category: "Agents",
hidden: true,
run: ({ arg }) => {
if (typeof arg === "string") openTask(arg);
},
},
{
id: "agents.archiveTasks",
title: "Archive Agent Tasks",
category: "Agents",
hidden: true,
run: ({ arg }) =>
runTasks(resolveTasks(arg).filter(canArchive), (task) => archiveTask(task.id), tx("Cannot archive agent tasks")),
},
{
id: "agents.deleteTasks",
title: "Delete Agent Tasks",
category: "Agents",
hidden: true,
run: ({ arg }) => confirmDelete(resolveTasks(arg)),
},
];
export function taskMenu(tasks: AgentTask[]): MenuItem[] {
if (!tasks.length) return [];
const ids = tasks.map((task) => task.id);
const task = tasks[0]!;
const multiple = tasks.length > 1;
const archivable = tasks.filter(canArchive).length;
return [
...(!multiple
? ([
{ kind: "action", action: "agents.openTask", label: "Open", arg: task.id },
{ kind: "action", action: "terminal.openAt", label: "Open in Terminal", icon: "terminal", arg: task.cwd },
{ kind: "separator" },
] satisfies MenuItem[])
: []),
{
kind: "action",
action: "agents.archiveTasks",
label: multiple ? tx`Archive (${archivable})` : "Archive",
arg: ids,
disabled: !archivable,
},
{
kind: "action",
action: "agents.deleteTasks",
label: multiple ? tx`Delete (${tasks.length})…` : "Delete…",
arg: ids,
},
];
}
+10 -1
View File
@@ -4739,5 +4739,14 @@
"Cannot select Python interpreter": "无法选择 Python 解释器",
"Python Interpreter: {label}": "Python 解释器:{label}",
"Add to .gitignore": "添加到 .gitignore",
"Add to .git/info/exclude": "添加到 .git/info/exclude"
"Add to .git/info/exclude": "添加到 .git/info/exclude",
"Delete Agent Tasks": "删除 Agent 任务",
"Open Agent Task": "打开 Agent 任务",
"Archive Agent Tasks": "归档 Agent 任务",
"Delete {count} agent tasks? Their branches and files outside worktrees are kept.": "是否删除这 {count} 个 Agent 任务?它们的分支及 worktree 以外的文件将保留。",
"Also remove their worktree folders": "同时删除它们的 worktree 目录",
"Cannot delete agent tasks": "无法删除 Agent 任务",
"Cannot archive agent tasks": "无法归档 Agent 任务",
"Archive ({count})": "归档({count})",
"Delete ({count})…": "删除({count})…"
}
+1
View File
@@ -267,6 +267,7 @@ export function Tree<T>(props: TreeProps<T>) {
commitSelection(next, row.key);
} else {
const a = anchor != null ? (indexOfKey.get(anchor) ?? i) : leadIndex >= 0 ? leadIndex : i;
setAnchor(rows[a]!.key);
const [s, e] = a < i ? [a, i] : [i, a];
const next = new Set<string>();
for (let j = s; j <= e; j++) if (!rows[j]!.loading) next.add(rows[j]!.key);