From aac924517ce457487f516346369fa7232aaa42c8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:53:02 +0800 Subject: [PATCH] fix(agents): preserve Shift selection and add bulk task actions (#283) --- src/features/agents/AgentsView.test.tsx | 209 ++++++++++++++++++++++++ src/features/agents/AgentsView.tsx | 50 ++---- src/features/agents/index.ts | 2 + src/features/agents/taskActions.ts | 115 +++++++++++++ src/platform/locales/text/zh.json | 11 +- src/ui/Tree.tsx | 1 + 6 files changed, 352 insertions(+), 36 deletions(-) create mode 100644 src/features/agents/AgentsView.test.tsx create mode 100644 src/features/agents/taskActions.ts diff --git a/src/features/agents/AgentsView.test.tsx b/src/features/agents/AgentsView.test.tsx new file mode 100644 index 00000000..8d831a98 --- /dev/null +++ b/src/features/agents/AgentsView.test.tsx @@ -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()), + maybeEditorService: () => ({ + activeInput: () => editor.active, + onActiveInputChange: (listener: typeof editor.listener) => { + editor.listener = listener; + return () => { + editor.listener = undefined; + }; + }, + }), +})); + +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 () => {}), + 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( + <> + + + , + ); +} + +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"); + }); +}); diff --git a/src/features/agents/AgentsView.tsx b/src/features/agents/AgentsView.tsx index 2f899c16..b5b50ca3 100644 --- a/src/features/agents/AgentsView.tsx +++ b/src/features/agents/AgentsView.tsx @@ -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" ? ( @@ -143,29 +149,3 @@ export function AgentsView() { ); } - -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); -} diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts index 2deed65d..e19dabb2 100644 --- a/src/features/agents/index.ts +++ b/src/features/agents/index.ts @@ -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", diff --git a/src/features/agents/taskActions.ts b/src/features/agents/taskActions.ts new file mode 100644 index 00000000..c7c63b11 --- /dev/null +++ b/src/features/agents/taskActions.ts @@ -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, 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, + }, + ]; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e506b0f7..633ca3fd 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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})…" } diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index a5b07581..0f0e1304 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -267,6 +267,7 @@ export function Tree(props: TreeProps) { 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(); for (let j = s; j <= e; j++) if (!rows[j]!.loading) next.add(rows[j]!.key);