mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(agents): preserve Shift selection and add bulk task actions (#283)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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})…"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user