mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
feat(agents): dock a session in the Agents tool window (#588)
Agent sessions can be opened in the Agents tool window beside the editor (Open in Tool Window / Open in Editor in the task menus, or by dragging between the editor and the tool window); opening in an editor tab stays the default. Fixes in review: - A docked conversation kept the previous task's subagent view after switching tasks: the session's local state is now keyed by task. - Moving a conversation between the editor and the tool window lost its scroll position: position and follow-output are kept per conversation (task or subagent). - In a short bottom tool window the message area shrank to a few pixels: wide windows place the task list beside the conversation, narrow ones cap the list height. - Drag and drop and list selection now go through the agents.dockTask / undockTask / openTask actions. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com>
This commit is contained in:
co-authored by
a-rookie-of-C-language
parent
fa26ba842c
commit
9847fd2c01
@@ -0,0 +1,119 @@
|
||||
/** Docking agent conversations between the editor and the Agents tool window by drag & drop. */
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const TASK = {
|
||||
id: "t1",
|
||||
title: "Repro task",
|
||||
status: "ready" as const,
|
||||
isolation: "local" as const,
|
||||
provider: "codex",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
/** Seeds one task in the agents store (no backend in plain-browser mode) and docks it. */
|
||||
async function seedAndDock(page: Page) {
|
||||
await page.evaluate(async (task) => {
|
||||
// Paths go through variables: knip can't resolve these browser-absolute URLs.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const store = await import(storePath);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
store.dockTask(task.id);
|
||||
}, TASK);
|
||||
await page.locator("span[title='Repro task']").first().waitFor();
|
||||
}
|
||||
|
||||
test("drags the docked conversation back onto the editor", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
// An editor pane, so the session tab has a content drop target to land on.
|
||||
await page.getByRole("treeitem", { name: "README.md", exact: true }).dblclick();
|
||||
await page.getByRole("tab", { name: "README.md" }).waitFor();
|
||||
await seedAndDock(page);
|
||||
|
||||
const title = page.locator("span[title='Repro task']").first();
|
||||
const to = await page.getByRole("tabpanel").first().boundingBox();
|
||||
const from = await title.boundingBox();
|
||||
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.getByRole("tab", { name: "Repro task" })).toBeVisible();
|
||||
await expect(page.getByRole("tree", { name: "Agent tasks" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("drags the docked conversation back onto the empty editor", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
// Docking closes the session tab; with no other tabs the editor is empty and
|
||||
// must still take the drop (regression: the empty state had no drop target).
|
||||
await seedAndDock(page);
|
||||
await expect(page.locator("[data-editor-area] [role='tabpanel']")).toHaveCount(0);
|
||||
|
||||
const title = page.locator("span[title='Repro task']").first();
|
||||
const to = await page.getByText("Drop files here to open them").boundingBox();
|
||||
const from = await title.boundingBox();
|
||||
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.getByRole("tab", { name: "Repro task" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("docks a session tab dropped on the tool window", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
await page.getByRole("treeitem", { name: "README.md", exact: true }).dblclick();
|
||||
await page.getByRole("tab", { name: "README.md" }).waitFor();
|
||||
await page.evaluate(async (task) => {
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const modelPath = "/src/features/agents/model.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const store = await import(storePath);
|
||||
const { TOOL_WINDOW } = await import(modelPath);
|
||||
const { toolWindowService } = await import(contributionsPath);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
toolWindowService().show(TOOL_WINDOW, true);
|
||||
store.openTask(task.id);
|
||||
}, TASK);
|
||||
await page.getByRole("tab", { name: "Repro task" }).waitFor();
|
||||
|
||||
const tab = page.getByRole("tab", { name: "Repro task" });
|
||||
const to = await page.getByRole("tree", { name: "Agent tasks" }).boundingBox();
|
||||
const from = await tab.boundingBox();
|
||||
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Docked: the tab closes and the tool window gives itself to the conversation.
|
||||
await expect(page.getByRole("tab", { name: "Repro task" })).toHaveCount(0);
|
||||
await expect(page.getByRole("tree", { name: "Agent tasks" })).toHaveCount(0);
|
||||
await expect(page.locator("span[title='Repro task']").first()).toBeVisible();
|
||||
});
|
||||
@@ -1458,6 +1458,11 @@ export class EditorController implements SlotHost {
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/** Input of the tab `tabId` in pane `paneId` (dragging a tab between views), null if there is none. */
|
||||
tabInput(paneId: string, tabId: string): EditorInput | null {
|
||||
return useTabs.getState().panes[paneId]?.tabs.find((t) => t.id === tabId)?.input ?? null;
|
||||
}
|
||||
|
||||
closePath(path: string): Promise<boolean> {
|
||||
return this.removeGuarded((s) => {
|
||||
const keys = new Set<string>();
|
||||
@@ -1682,7 +1687,10 @@ export class EditorController implements SlotHost {
|
||||
{ kind: "action", action: "project.rename", arg: path, label: "Rename File…" },
|
||||
{ kind: "separator" },
|
||||
] as MenuItem[])
|
||||
: []),
|
||||
: [
|
||||
// Contributed groups for tabs without a file (e.g. "Open in Tool Window").
|
||||
...menuGroupItems("editorTab", { paths: [], input: tab.input }),
|
||||
]),
|
||||
{ kind: "action", action: "file.settings", arg: "editor.general.editorTabs", label: "Configure Editor Tabs…" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export function activate(): void | (() => void) {
|
||||
focus: () => ctrl.focus(),
|
||||
openPaths: () => ctrl.openPaths(),
|
||||
openTabs: () => ctrl.openTabs(),
|
||||
tabInput: (paneId, tabId) => ctrl.tabInput(paneId, tabId),
|
||||
activeInput: () => ctrl.activeInput(),
|
||||
onActiveInputChange: (fn) => ctrl.onActiveInputChange(fn),
|
||||
closePath: (path) => ctrl.closePath(path),
|
||||
|
||||
@@ -13,6 +13,9 @@ import { setContext } from "@/platform/context";
|
||||
import { useEditorPrefs } from "@/platform/editorSettings";
|
||||
import { basename, dirname } from "@/platform/project";
|
||||
import {
|
||||
EDITOR_TAB_MIME,
|
||||
acceptsEditorDrop,
|
||||
dropOnEditor,
|
||||
getFileDecoration,
|
||||
prepareMenuGroups,
|
||||
textEditorWrapperFor,
|
||||
@@ -54,7 +57,7 @@ import { tx } from "@/platform/i18n";
|
||||
export function EditorAreaView() {
|
||||
const layout = useTabs((s) => s.layout);
|
||||
const empty = useTabs((s) => Object.values(s.panes).every((p) => p.tabs.length === 0));
|
||||
if (empty && layout.kind === "pane") return <EmptyEditor />;
|
||||
if (empty && layout.kind === "pane") return <EmptyEditorArea />;
|
||||
return (
|
||||
<div className={styles.area}>
|
||||
<LayoutView node={layout} />
|
||||
@@ -62,6 +65,42 @@ export function EditorAreaView() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The empty editor still accepts drops: reopening tabs, opening files, feature drags (agent tasks). */
|
||||
function EmptyEditorArea() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [over, setOver] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
return registerDropTarget(el, {
|
||||
accepts: (type) => type === EDITOR_TAB_MIME || type === FILES_DRAG || acceptsEditorDrop(type),
|
||||
over: () => setOver(true),
|
||||
leave: () => setOver(false),
|
||||
drop: (data) => {
|
||||
setOver(false);
|
||||
const paneId = Object.keys(useTabs.getState().panes)[0];
|
||||
if (!paneId) return;
|
||||
if (data.type === FILES_DRAG) {
|
||||
void editorController().openFilesIn(data.payload.split("\n").filter(Boolean), paneId, "center");
|
||||
return;
|
||||
}
|
||||
if (data.type !== EDITOR_TAB_MIME) {
|
||||
dropOnEditor(data);
|
||||
return;
|
||||
}
|
||||
const p = parseTabPayload(data.payload);
|
||||
if (p) editorController().moveTab(p.paneId, p.tabId, paneId, 0);
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<div ref={ref} className={styles.area}>
|
||||
<EmptyEditor />
|
||||
{over && <div className={clsx(styles.dropZone, styles["drop-center"])} aria-hidden="true" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutView({ node }: { node: LayoutNode }): ReactNode {
|
||||
if (node.kind === "pane") return <PaneView paneId={node.paneId} />;
|
||||
return (
|
||||
@@ -78,9 +117,6 @@ function LayoutView({ node }: { node: LayoutNode }): ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
/** MIME type of a dragged editor tab: `{"paneId","tabId"}` JSON. */
|
||||
export const EDITOR_TAB_MIME = "application/x-openidea-editor-tab";
|
||||
|
||||
type DropZone = SplitEdge | "center";
|
||||
|
||||
/** Where a tab dropped at (x, y) over an editor goes: an edge (new split) or the pane itself. */
|
||||
@@ -232,8 +268,9 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
if (!el) return;
|
||||
const zoneAt = (x: number, y: number) => dropZoneAt(x, y, el.getBoundingClientRect());
|
||||
return registerDropTarget(el, {
|
||||
accepts: (type) => type === EDITOR_TAB_MIME || type === FILES_DRAG,
|
||||
over: (_, x, y) => setDropZone(zoneAt(x, y)),
|
||||
accepts: (type) => type === EDITOR_TAB_MIME || type === FILES_DRAG || acceptsEditorDrop(type),
|
||||
over: (data, x, y) =>
|
||||
setDropZone(data.type === EDITOR_TAB_MIME || data.type === FILES_DRAG ? zoneAt(x, y) : "center"),
|
||||
leave: () => setDropZone(null),
|
||||
drop: (data, x, y) => {
|
||||
const zone = zoneAt(x, y);
|
||||
@@ -242,6 +279,10 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
void ctrl.openFilesIn(data.payload.split("\n").filter(Boolean), paneId, zone);
|
||||
return;
|
||||
}
|
||||
if (data.type !== EDITOR_TAB_MIME) {
|
||||
dropOnEditor(data);
|
||||
return;
|
||||
}
|
||||
const p = parseTabPayload(data.payload);
|
||||
if (!p) return;
|
||||
const target = useTabs.getState().panes[paneId];
|
||||
|
||||
@@ -28,6 +28,9 @@ const { createMemoryBackend } = await import("./backend");
|
||||
const { initialTabsState } = await import("./model/tabs");
|
||||
const { serializeSession } = await import("./model/session");
|
||||
const { useDocMeta } = await import("./model/document");
|
||||
const { registerMenuGroup } = await import("@/workbench/contributions");
|
||||
const { registerActions } = await import("@/platform/actions");
|
||||
const { resolveMenuItems } = await import("@/ui");
|
||||
|
||||
let ctrl: InstanceType<typeof EditorController> | null = null;
|
||||
|
||||
@@ -85,6 +88,25 @@ describe("virtual editor input", () => {
|
||||
.tabMenu(useTabs.getState().activePane, activeTab())
|
||||
.some((i) => "label" in i && i.label === "Reveal in Project"),
|
||||
).toBe(false);
|
||||
|
||||
// Pathless tabs still get contributed groups, with the tab's input in the context.
|
||||
const disposeAction = registerActions([{ id: "test.tab", title: "Test Item", run: () => undefined }]);
|
||||
const disposeGroup = registerMenuGroup({
|
||||
id: "test.tab",
|
||||
menus: ["editorTab"],
|
||||
order: 50,
|
||||
items: (_menu, { paths, input }) =>
|
||||
paths.length || input?.kind !== "virtual"
|
||||
? []
|
||||
: [{ kind: "action", action: "test.tab", label: "Test Item", arg: input.key }],
|
||||
});
|
||||
try {
|
||||
const items = resolveMenuItems(ctrl.tabMenu(useTabs.getState().activePane, activeTab()));
|
||||
expect(items.some((i) => "label" in i && i.label === "Test Item")).toBe(true);
|
||||
} finally {
|
||||
disposeGroup();
|
||||
disposeAction();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not open a tab when the server has no contents", async () => {
|
||||
|
||||
@@ -3,10 +3,22 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.view > * {
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.content > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
/* A session editor tab hovers over the tool window: it will dock here. */
|
||||
.dropTarget {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -124,3 +136,62 @@
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* A conversation docked in the tool window, above the task list. */
|
||||
.docked {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dockedBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dockedTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dockedSession {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tasks {
|
||||
flex: 2 1 0;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Keep the conversation usable in a short bottom tool window; the tree scrolls. */
|
||||
.docked + .tasks {
|
||||
flex: 0 0 20%;
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
/* A wide bottom window has room beside the conversation, preserving message height. */
|
||||
@container (min-width: 600px) {
|
||||
.content {
|
||||
flex-direction: row;
|
||||
}
|
||||
.docked + .tasks {
|
||||
flex: 0 0 220px;
|
||||
max-width: 30%;
|
||||
max-height: none;
|
||||
border-top: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { AgentTask } from "@/bindings";
|
||||
import * as actions from "@/platform/actions";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import type { EditorInput } from "@/workbench/contributions";
|
||||
import { EDITOR_TAB_MIME, type EditorInput } from "@/workbench/contributions";
|
||||
import { confirmChecked, showError } from "@/workbench/messages";
|
||||
import { ContextMenuHost, hideContextMenu } from "@/ui";
|
||||
import { ContextMenuHost, dragSession, hideContextMenu } from "@/ui";
|
||||
import { AgentsView } from "./AgentsView";
|
||||
import { SESSION_KIND } from "./model";
|
||||
import { archiveTask, unarchiveTask, deleteTask, openTask, useAgents } from "./store";
|
||||
import { taskActions } from "./taskActions";
|
||||
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, undockTask, useAgents } from "./store";
|
||||
import { taskActions, taskMenu } from "./taskActions";
|
||||
import styles from "./Agents.module.css";
|
||||
|
||||
const editor = vi.hoisted(() => ({
|
||||
active: undefined as EditorInput | undefined,
|
||||
listener: undefined as ((input: EditorInput) => void) | undefined,
|
||||
tabInput: undefined as ((paneId: string, tabId: string) => EditorInput | null) | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/workbench/contributions", async (original) => ({
|
||||
@@ -25,6 +28,7 @@ vi.mock("@/workbench/contributions", async (original) => ({
|
||||
editor.listener = undefined;
|
||||
};
|
||||
},
|
||||
tabInput: editor.tabInput,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -34,8 +38,12 @@ vi.mock("./store", async (original) => ({
|
||||
archiveTask: vi.fn(async () => {}),
|
||||
unarchiveTask: vi.fn(async () => {}),
|
||||
deleteTask: vi.fn(async () => {}),
|
||||
dockTask: vi.fn(),
|
||||
undockTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./SessionEditor", () => ({ SessionEditor: () => <div data-testid="docked-session" /> }));
|
||||
|
||||
vi.mock("@/workbench/messages", () => ({
|
||||
confirmChecked: vi.fn(async () => ({ checked: false })),
|
||||
showError: vi.fn(async () => {}),
|
||||
@@ -67,7 +75,10 @@ function task(
|
||||
};
|
||||
}
|
||||
|
||||
const row = (id: string) => screen.getByText(id).closest('[role="treeitem"]')!;
|
||||
const row = (id: string) =>
|
||||
within(screen.getByRole("tree", { name: "Agent tasks" }))
|
||||
.getByText(id)
|
||||
.closest('[role="treeitem"]')!;
|
||||
const select = (id: string, modifiers: MouseEventInit = {}) =>
|
||||
fireEvent.mouseDown(row(id), { button: 0, ...modifiers });
|
||||
const selectedTasks = () =>
|
||||
@@ -83,18 +94,22 @@ const context = (id: string) => {
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(actions, "executeAction");
|
||||
editor.active = undefined;
|
||||
editor.tabInput = 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])),
|
||||
docked: null,
|
||||
});
|
||||
dispose = registerActions(taskActions);
|
||||
});
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
hideContextMenu();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setup() {
|
||||
@@ -142,6 +157,50 @@ describe("Agents selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agents docked view", () => {
|
||||
it("shows the docked conversation above the list and switches it by selecting a task", () => {
|
||||
useAgents.setState({ docked: "one" });
|
||||
setup();
|
||||
expect(screen.getByTestId("docked-session")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Open in Editor" })).toBeTruthy();
|
||||
expect(openTask).not.toHaveBeenCalled();
|
||||
select("two");
|
||||
expect(actions.executeAction).toHaveBeenCalledWith("agents.dockTask", "two");
|
||||
expect(dockTask).toHaveBeenCalledWith("two");
|
||||
expect(openTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the docked task from the list below", () => {
|
||||
useAgents.setState({ docked: "one" });
|
||||
setup();
|
||||
const tree = within(screen.getByRole("tree", { name: "Agent tasks" }));
|
||||
expect(tree.queryByText("one")).toBeNull();
|
||||
expect(tree.getByText("two")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("gives the whole tool window to the conversation when the docked task is the only one", () => {
|
||||
useAgents.setState({ tasks: new Map([task("one")].map((t) => [t.id, t])), docked: "one" });
|
||||
setup();
|
||||
expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull();
|
||||
expect(screen.getByTestId("docked-session")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("undocks from the docked conversation's bar", () => {
|
||||
useAgents.setState({ docked: "one" });
|
||||
setup();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open in Editor" }));
|
||||
expect(undockTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens tabs as usual without a docked conversation", () => {
|
||||
setup();
|
||||
expect(screen.queryByRole("button", { name: "Open in Editor" })).toBeNull();
|
||||
select("two");
|
||||
expect(openTask).toHaveBeenCalledWith("two");
|
||||
expect(dockTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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")];
|
||||
@@ -221,3 +280,68 @@ describe("Agents bulk actions", () => {
|
||||
expect(showError).toHaveBeenCalledWith("Cannot delete agent tasks", "one: Cannot remove worktree");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agents task menu", () => {
|
||||
it("offers to dock a task into the tool window", async () => {
|
||||
setup();
|
||||
context("one");
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Open in Tool Window" }));
|
||||
await waitFor(() => expect(dockTask).toHaveBeenCalledWith("one"));
|
||||
});
|
||||
|
||||
it("offers to move the docked task back to an editor", async () => {
|
||||
useAgents.setState({ docked: "one" });
|
||||
setup();
|
||||
// The docked task is not listed; its menu lives on the docked conversation's header.
|
||||
const menu = taskMenu([useAgents.getState().tasks.get("one")!]);
|
||||
expect(menu).toContainEqual(expect.objectContaining({ action: "agents.undockTask", label: "Open in Editor" }));
|
||||
await taskActions.find((action) => action.id === "agents.undockTask")!.run({ ctx: {}, arg: "one" });
|
||||
await waitFor(() => expect(undockTask).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agents drag & drop", () => {
|
||||
// jsdom has no elementFromPoint; the drop target is found through it.
|
||||
const pointAt = (el: Element) => {
|
||||
const original = document.elementFromPoint;
|
||||
document.elementFromPoint = () => el;
|
||||
return () => {
|
||||
document.elementFromPoint = original;
|
||||
};
|
||||
};
|
||||
|
||||
it("docks a session editor tab dropped on the tool window and highlights while it hovers", () => {
|
||||
editor.tabInput = (paneId, tabId) =>
|
||||
paneId === "p" && tabId === "t" ? { kind: SESSION_KIND, key: "agent:one", data: { task: "one" } } : null;
|
||||
setup();
|
||||
const view = document.querySelector(`.${styles.view}`)!;
|
||||
const restore = pointAt(view);
|
||||
try {
|
||||
const session = dragSession({ type: EDITOR_TAB_MIME, payload: JSON.stringify({ paneId: "p", tabId: "t" }) });
|
||||
act(() => session.move(10, 10));
|
||||
expect(view.className).toContain(styles.dropTarget);
|
||||
act(() => session.drop(10, 10));
|
||||
expect(actions.executeAction).toHaveBeenCalledWith("agents.dockTask", "one");
|
||||
expect(dockTask).toHaveBeenCalledWith("one");
|
||||
expect(view.className).not.toContain(styles.dropTarget);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores editor tab drags that are not agent sessions", () => {
|
||||
editor.tabInput = () => null;
|
||||
setup();
|
||||
const view = document.querySelector(`.${styles.view}`)!;
|
||||
const restore = pointAt(view);
|
||||
try {
|
||||
const session = dragSession({ type: EDITOR_TAB_MIME, payload: JSON.stringify({ paneId: "p", tabId: "t" }) });
|
||||
act(() => session.move(10, 10));
|
||||
expect(view.className).not.toContain(styles.dropTarget);
|
||||
act(() => session.drop(10, 10));
|
||||
expect(dockTask).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
/** Agents tool window: tasks grouped by what they need from the user. */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { AgentStatus as Status, AgentTask } from "@/bindings";
|
||||
import { maybeEditorService, type EditorInput } from "@/workbench/contributions";
|
||||
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 { EDITOR_TAB_MIME, maybeEditorService, type EditorInput } from "@/workbench/contributions";
|
||||
import {
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
Spinner,
|
||||
T,
|
||||
Tree,
|
||||
registerDropTarget,
|
||||
showContextMenu,
|
||||
startPointerDrag,
|
||||
} from "@/ui";
|
||||
import { SESSION_KIND, TASK_DRAG, age, branchLabel, groupTasks, sessionKey, type TaskGroup } from "./model";
|
||||
import { openHome, useAgents } from "./store";
|
||||
import { taskMenu } from "./taskActions";
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import styles from "./Agents.module.css";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
|
||||
export function AgentsHeaderActions() {
|
||||
return (
|
||||
@@ -25,6 +38,18 @@ const nodeKey = (n: Node) => (n.kind === "group" ? `g:${n.group.status}` : n.tas
|
||||
const sessionTask = (input: EditorInput | null | undefined) =>
|
||||
input?.kind === SESSION_KIND ? (input.data as { task?: string } | undefined)?.task : undefined;
|
||||
|
||||
/** The task of an editor tab drag payload (session tabs only), null for anything else. */
|
||||
const sessionPayloadTask = (payload: string): string | null => {
|
||||
try {
|
||||
const p = JSON.parse(payload) as { paneId?: unknown; tabId?: unknown };
|
||||
if (typeof p.paneId !== "string" || typeof p.tabId !== "string") return null;
|
||||
const input = maybeEditorService()?.tabInput?.(p.paneId, p.tabId);
|
||||
return input?.kind === SESSION_KIND ? ((input.data as { task?: string }).task ?? null) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function TaskStatusIcon({ status }: { status: Status }) {
|
||||
switch (status) {
|
||||
case "needsInput":
|
||||
@@ -51,6 +76,7 @@ export function AgentsView() {
|
||||
const tasks = useAgents((s) => s.tasks);
|
||||
const unread = useAgents((s) => s.unread);
|
||||
const providers = useAgents((s) => s.providers);
|
||||
const docked = useAgents((s) => s.docked);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["g:archived"]));
|
||||
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||
const task = sessionTask(maybeEditorService()?.activeInput?.());
|
||||
@@ -69,8 +95,29 @@ export function AgentsView() {
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const roots = useMemo<Node[]>(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]);
|
||||
// The docked conversation is already shown above; listing it again is noise.
|
||||
const roots = useMemo<Node[]>(
|
||||
() => groupTasks([...tasks.values()].filter((t) => t.id !== docked)).map((group) => ({ kind: "group", group })),
|
||||
[tasks, docked],
|
||||
);
|
||||
const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]);
|
||||
// Dropping a session editor tab anywhere on the tool window docks it.
|
||||
const viewRef = useRef<HTMLDivElement>(null);
|
||||
const [tabOver, setTabOver] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = viewRef.current;
|
||||
if (!el) return;
|
||||
return registerDropTarget(el, {
|
||||
accepts: (type) => type === EDITOR_TAB_MIME,
|
||||
over: (data) => setTabOver(sessionPayloadTask(data.payload) != null),
|
||||
leave: () => setTabOver(false),
|
||||
drop: (data) => {
|
||||
setTabOver(false);
|
||||
const task = sessionPayloadTask(data.payload);
|
||||
if (task) void executeAction("agents.dockTask", task);
|
||||
},
|
||||
});
|
||||
}, [loaded, error]);
|
||||
if (!loaded)
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
@@ -79,80 +126,125 @@ export function AgentsView() {
|
||||
);
|
||||
if (error) return <EmptyState title="Cannot load agent tasks">{error}</EmptyState>;
|
||||
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
|
||||
// While a conversation is docked, clicking a task switches it instead of opening a tab.
|
||||
const showTask = (task: string) => void executeAction(docked ? "agents.dockTask" : "agents.openTask", task);
|
||||
const dockedTitle = docked ? (tasks.get(docked)?.title ?? docked) : null;
|
||||
const dragTask = (e: ReactPointerEvent<HTMLSpanElement>, task: string) =>
|
||||
startPointerDrag(e, e.currentTarget, { type: TASK_DRAG, payload: task });
|
||||
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<Tree<Node>
|
||||
aria-label="Agent tasks"
|
||||
roots={roots}
|
||||
getKey={nodeKey}
|
||||
getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)}
|
||||
getChildren={(n) =>
|
||||
n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined
|
||||
}
|
||||
isExpandable={(n) => n.kind === "group"}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(_next, node, open) =>
|
||||
setCollapsed((c) => {
|
||||
const s = new Set(c);
|
||||
if (open) s.delete(nodeKey(node));
|
||||
else s.add(nodeKey(node));
|
||||
return s;
|
||||
})
|
||||
}
|
||||
selected={selected}
|
||||
onSelectionChange={(sel, lead) => {
|
||||
setSelected(sel);
|
||||
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) => {
|
||||
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}>
|
||||
<span className={styles.groupLabel}>
|
||||
<T>{n.group.label}</T>
|
||||
<div ref={viewRef} className={clsx(styles.view, tabOver && styles.dropTarget)}>
|
||||
<div className={styles.content}>
|
||||
{docked && (
|
||||
<div className={styles.docked}>
|
||||
<div className={styles.dockedBar}>
|
||||
<span
|
||||
className={styles.dockedTitle}
|
||||
title={dockedTitle ?? undefined}
|
||||
onPointerDown={(e) => dragTask(e, docked)}
|
||||
>
|
||||
{dockedTitle}
|
||||
</span>
|
||||
<span className={styles.count}>{n.group.tasks.length}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.row} title={n.task.error ?? n.task.title}>
|
||||
<TaskStatusIcon status={n.task.status} />
|
||||
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
|
||||
{n.task.title}
|
||||
</span>
|
||||
{unread.has(n.task.id) && (
|
||||
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
|
||||
)}
|
||||
{project?.workspace && (
|
||||
<span className={styles.taskProject} title={n.task.projectRoot ?? project.root}>
|
||||
{basename(n.task.projectRoot ?? project.root)}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.detail}>
|
||||
{providerName(n.task.provider)}
|
||||
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
|
||||
</span>
|
||||
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
empty={
|
||||
<div className={styles.empty}>
|
||||
<T>No agent tasks yet</T>
|
||||
<button type="button" className={styles.link} onClick={() => openHome()}>
|
||||
<T>New Task…</T>
|
||||
</button>
|
||||
<IconButton
|
||||
icon="editor-only"
|
||||
action="agents.undockTask"
|
||||
actionArg={docked}
|
||||
title="Open in Editor"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.dockedSession}>
|
||||
<SessionEditor
|
||||
input={{
|
||||
kind: SESSION_KIND,
|
||||
key: sessionKey(docked),
|
||||
data: { task: docked, title: tasks.get(docked)?.title },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* Docked with nothing else to list: give the whole window to the conversation. */}
|
||||
{(roots.length > 0 || !docked) && (
|
||||
<div className={styles.tasks}>
|
||||
<Tree<Node>
|
||||
aria-label="Agent tasks"
|
||||
roots={roots}
|
||||
getKey={nodeKey}
|
||||
getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)}
|
||||
getChildren={(n) =>
|
||||
n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined
|
||||
}
|
||||
isExpandable={(n) => n.kind === "group"}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(_next, node, open) =>
|
||||
setCollapsed((c) => {
|
||||
const s = new Set(c);
|
||||
if (open) s.delete(nodeKey(node));
|
||||
else s.add(nodeKey(node));
|
||||
return s;
|
||||
})
|
||||
}
|
||||
selected={selected}
|
||||
onSelectionChange={(sel, lead) => {
|
||||
setSelected(sel);
|
||||
if (sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) showTask(lead.task.id);
|
||||
}}
|
||||
onActivate={(n) => n.kind === "task" && showTask(n.task.id)}
|
||||
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}>
|
||||
<span className={styles.groupLabel}>
|
||||
<T>{n.group.label}</T>
|
||||
</span>
|
||||
<span className={styles.count}>{n.group.tasks.length}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={styles.row}
|
||||
title={n.task.error ?? n.task.title}
|
||||
onPointerDown={(e) => dragTask(e, n.task.id)}
|
||||
>
|
||||
<TaskStatusIcon status={n.task.status} />
|
||||
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
|
||||
{n.task.title}
|
||||
</span>
|
||||
{unread.has(n.task.id) && (
|
||||
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
|
||||
)}
|
||||
{project?.workspace && (
|
||||
<span className={styles.taskProject} title={n.task.projectRoot ?? project.root}>
|
||||
{basename(n.task.projectRoot ?? project.root)}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.detail}>
|
||||
{providerName(n.task.provider)}
|
||||
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
|
||||
</span>
|
||||
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
empty={
|
||||
<div className={styles.empty}>
|
||||
<T>No agent tasks yet</T>
|
||||
<button type="button" className={styles.link} onClick={() => openHome()}>
|
||||
<T>New Task…</T>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* The conversation hugs its content; spare space stays below the composer
|
||||
instead of inside the chat. */
|
||||
.streamArea,
|
||||
.stream {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -70,7 +70,13 @@ import styles from "./Session.module.css";
|
||||
|
||||
export function SessionEditor({ input }: { input: EditorInput }) {
|
||||
const id = (input.data as { task: string }).task;
|
||||
return <TaskSession key={id} id={id} />;
|
||||
}
|
||||
|
||||
function TaskSession({ id }: { id: string }) {
|
||||
const task = useAgents((s) => s.tasks.get(id));
|
||||
// The tool window bar above already shows the title of a docked conversation.
|
||||
const docked = useAgents((s) => s.docked === id);
|
||||
const entries = useAgents((s) => s.entries.get(id));
|
||||
const changes = useAgents((s) => s.changes.get(id));
|
||||
const project = useProject((s) => s.project);
|
||||
@@ -113,7 +119,7 @@ export function SessionEditor({ input }: { input: EditorInput }) {
|
||||
if (!task) return <EmptyState title="This agent task no longer exists" />;
|
||||
return (
|
||||
<div
|
||||
className={styles.session}
|
||||
className={clsx(styles.session, docked && styles.docked)}
|
||||
onFocusCapture={() => setFocusedSession(session)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedSession() === session) {
|
||||
@@ -129,6 +135,7 @@ export function SessionEditor({ input }: { input: EditorInput }) {
|
||||
changes={changes ?? null}
|
||||
changesOpen={changesOpen}
|
||||
onToggleChanges={() => setChangesOpen(!changesOpen)}
|
||||
docked={docked}
|
||||
/>
|
||||
{view && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
|
||||
<PermissionBanner task={task} entries={entries ?? null} />
|
||||
@@ -193,6 +200,7 @@ function Header({
|
||||
changes,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
docked,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
@@ -200,6 +208,8 @@ function Header({
|
||||
changes: AgentChange[] | null;
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
/** The tool window bar above a docked conversation already shows the title. */
|
||||
docked?: boolean;
|
||||
}) {
|
||||
const project = useProject((s) => s.project);
|
||||
const projectName = project?.workspace ? basename(task.projectRoot ?? project.root) : null;
|
||||
@@ -215,9 +225,11 @@ function Header({
|
||||
}}
|
||||
>
|
||||
<TaskStatusIcon status={task.status} />
|
||||
<span className={styles.title} title={task.title}>
|
||||
{task.title}
|
||||
</span>
|
||||
{!docked && (
|
||||
<span className={styles.title} title={task.title}>
|
||||
{task.title}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.headerDetail} title={task.cwd}>
|
||||
<Icon name={providerIcon(task.provider)} size={13} className={styles.providerIcon} />
|
||||
<span className={styles.headerText}>
|
||||
@@ -372,11 +384,29 @@ interface StreamProps {
|
||||
function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, onCloseFind }: StreamProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useSelectionBoundary(ref);
|
||||
const stick = useRef(true);
|
||||
const key = entriesKey(task.id, subagent?.id ?? null);
|
||||
const positions = useAgents.getState().scrollPositions;
|
||||
const position = useRef(positions.get(key) ?? { top: 0, stick: true });
|
||||
const restored = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
if (!el || entries === null) return;
|
||||
if (position.current.stick) el.scrollTop = el.scrollHeight;
|
||||
else if (!restored.current) el.scrollTop = position.current.top;
|
||||
restored.current = true;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (position.current.stick) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (restored.current) positions.set(key, { ...position.current, top: el.scrollTop });
|
||||
};
|
||||
}, [key, positions]);
|
||||
const waiting = entries?.some((e) => e.type === "permission" && !e.chosen);
|
||||
const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user");
|
||||
// Forks and undo apply to the task's own turns, not a subagent's conversation.
|
||||
@@ -391,7 +421,8 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
tabIndex={-1}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
position.current = { top: el.scrollTop, stick: el.scrollHeight - el.scrollTop - el.clientHeight < 40 };
|
||||
positions.set(key, position.current);
|
||||
}}
|
||||
// Copy, plus Quote for a selection: replies to it in the composer.
|
||||
onContextMenu={(e) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { executeAction } from "@/platform/actions";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import {
|
||||
registerEditorDropHandler,
|
||||
registerEditorKind,
|
||||
registerMenuGroup,
|
||||
registerSettingsPage,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
toolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { AgentsStatus } from "./AgentsStatus";
|
||||
import { HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TOOL_WINDOW } from "./model";
|
||||
import { HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TASK_DRAG, TOOL_WINDOW } from "./model";
|
||||
import { sendPaths, sendSelection } from "./references";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import { connect, disconnect, openHome, useAgents } from "./store";
|
||||
@@ -79,6 +80,12 @@ export function activate(): () => void {
|
||||
icon: () => "toolwindow-agents",
|
||||
restorable: true,
|
||||
}),
|
||||
// A task dragged from the tool window onto the editor opens as a tab (or undocks).
|
||||
registerEditorDropHandler({
|
||||
accepts: (type) => type === TASK_DRAG,
|
||||
drop: ({ payload }) =>
|
||||
void executeAction(useAgents.getState().docked === payload ? "agents.undockTask" : "agents.openTask", payload),
|
||||
}),
|
||||
registerStatusItem({ id: "agents.status", align: "right", priority: 3, title: "Agents", component: AgentsStatus }),
|
||||
registerMenuGroup({
|
||||
id: "agents.send",
|
||||
@@ -89,6 +96,26 @@ export function activate(): () => void {
|
||||
? [{ kind: "action", action: "agents.sendToAgent", arg: menu === "projectView" ? paths : undefined }]
|
||||
: [],
|
||||
}),
|
||||
registerMenuGroup({
|
||||
// Session tabs have no path; "Open in Tool Window" docks them beside the task list.
|
||||
id: "agents.tab",
|
||||
menus: ["editorTab"],
|
||||
order: 50,
|
||||
items: (_menu, { paths, input }) => {
|
||||
const task = input?.kind === SESSION_KIND ? (input.data as { task?: string } | undefined)?.task : undefined;
|
||||
return paths.length || !task
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "action",
|
||||
action: "agents.dockTask",
|
||||
label: "Open in Tool Window",
|
||||
icon: "open-in-tool-window",
|
||||
arg: task,
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
registerActions([
|
||||
...taskActions,
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@ export const SESSION_KIND = "agent-session";
|
||||
export const HOME_KIND = "agent-home";
|
||||
export const HOME_KEY = "agent:new";
|
||||
export const TOOL_WINDOW = "agents";
|
||||
/** MIME of a dragged agent task (a tree row or the docked conversation bar): the payload is the task id. */
|
||||
export const TASK_DRAG = "application/x-openidea-agent-task";
|
||||
/** Settings › Tools › Agents: the ACP Registry. */
|
||||
export const REGISTRY_PAGE = "tools.agents";
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import type { AgentEntry, AgentTask } from "@/bindings";
|
||||
import { AgentsView } from "./AgentsView";
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./model";
|
||||
import { disconnect, useAgents } from "./store";
|
||||
import { taskActions } from "./taskActions";
|
||||
import styles from "./Session.module.css";
|
||||
|
||||
const backend = vi.hoisted(() => ({ entries: new Map<string, AgentEntry[]>() }));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
commands: {
|
||||
agentEntries: async (id: string, sub: string | null) => ({
|
||||
status: "ok",
|
||||
data: backend.entries.get(sub ? `${id}/${sub}` : id) ?? [],
|
||||
}),
|
||||
agentChanges: async () => ({ status: "ok", data: [] }),
|
||||
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", async (original) => ({
|
||||
...(await original<typeof import("@/workbench/contributions")>()),
|
||||
editorService: () => ({ closeInput: vi.fn(), openInput: vi.fn() }),
|
||||
toolWindowService: () => ({ show: vi.fn() }),
|
||||
}));
|
||||
|
||||
const task = (id: string): AgentTask => ({
|
||||
id,
|
||||
title: id,
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
isolation: "local",
|
||||
model: null,
|
||||
cwd: "/project",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
});
|
||||
const subagent: SubagentEntry = {
|
||||
type: "subagent",
|
||||
id: "child",
|
||||
title: "Explore task A",
|
||||
description: null,
|
||||
status: "done",
|
||||
error: null,
|
||||
prompt: null,
|
||||
reply: null,
|
||||
tools: 0,
|
||||
activity: null,
|
||||
startedMs: 0,
|
||||
endedMs: 1,
|
||||
cancellable: false,
|
||||
};
|
||||
const input = (id: string) => ({ kind: SESSION_KIND, key: sessionKey(id), data: { task: id } });
|
||||
const stream = () => document.querySelector<HTMLDivElement>(`.${styles.stream}`)!;
|
||||
|
||||
const scrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop");
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (scrollTopDescriptor) Object.defineProperty(HTMLElement.prototype, "scrollTop", scrollTopDescriptor);
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, "scrollTop");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
disconnect();
|
||||
backend.entries = new Map([
|
||||
["A", [{ type: "agent", text: "Answer A" }, subagent]],
|
||||
[entriesKey("A", "child"), [{ type: "agent", text: "Child answer" }]],
|
||||
["B", [{ type: "agent", text: "Answer B" }]],
|
||||
]);
|
||||
useAgents.setState({
|
||||
loaded: true,
|
||||
tasks: new Map([task("A"), task("B")].map((t) => [t.id, t])),
|
||||
entries: new Map(backend.entries),
|
||||
});
|
||||
});
|
||||
|
||||
describe("session state across tasks and hosts", () => {
|
||||
it("returns to the task conversation when the docked task changes after opening a subagent", async () => {
|
||||
const dispose = registerActions(taskActions);
|
||||
try {
|
||||
useAgents.setState({ docked: "A" });
|
||||
render(<AgentsView />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Explore task A/ }));
|
||||
expect(await screen.findByText("Child answer")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: "Message" })).not.toBeInTheDocument();
|
||||
fireEvent.mouseDown(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("B"), { button: 0 });
|
||||
expect(await screen.findByText("Answer B")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Message" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Child answer")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Subagents take their instructions from the agent that started them."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(document.querySelector(`.${styles.subagentBar}`)).toBeNull();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("retains scrolling and follow-output (%s) when moving in both directions", async (stick) => {
|
||||
// Give jsdom measurable scroll geometry; each host has a different viewport.
|
||||
let viewport = 400;
|
||||
let height = 2000;
|
||||
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(() => height);
|
||||
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(() => viewport);
|
||||
const tops = new WeakMap<HTMLElement, number>();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollTop", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return tops.get(this) ?? 0;
|
||||
},
|
||||
set(this: HTMLElement, top: number) {
|
||||
tops.set(this, Math.max(0, Math.min(top, height - viewport)));
|
||||
},
|
||||
});
|
||||
const view = render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Answer A");
|
||||
stream().scrollTop = stick ? height - viewport : 230;
|
||||
fireEvent.scroll(stream());
|
||||
expect(useAgents.getState().scrollPositions.get("A")).toEqual({ top: stick ? 1600 : 230, stick });
|
||||
view.unmount();
|
||||
viewport = 150;
|
||||
useAgents.setState({ docked: "A" });
|
||||
const dock = render(<AgentsView />);
|
||||
await screen.findByText("Answer A");
|
||||
expect(stream().scrollTop).toBe(stick ? height - viewport : 230);
|
||||
height = 2400;
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map(backend.entries).set("A", [
|
||||
...backend.entries.get("A")!,
|
||||
{ type: "agent", text: "More output" },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(stream().scrollTop).toBe(stick ? height - viewport : 230);
|
||||
dock.unmount();
|
||||
viewport = 400;
|
||||
useAgents.setState({ docked: null });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Answer A");
|
||||
expect(stream().scrollTop).toBe(stick ? height - viewport : 230);
|
||||
});
|
||||
|
||||
it("keeps task and subagent scroll positions separate", async () => {
|
||||
useAgents.getState().scrollPositions.set("A", { top: 120, stick: false });
|
||||
useAgents.getState().scrollPositions.set("A/child", { top: 70, stick: false });
|
||||
useAgents.getState().scrollPositions.set("B", { top: 340, stick: false });
|
||||
const view = render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Answer A");
|
||||
expect(stream().scrollTop).toBe(120);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Explore task A/ }));
|
||||
await screen.findByText("Child answer");
|
||||
expect(stream().scrollTop).toBe(70);
|
||||
view.rerender(<SessionEditor input={input("B")} />);
|
||||
await screen.findByText("Answer B");
|
||||
expect(stream().scrollTop).toBe(340);
|
||||
view.rerender(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Answer A");
|
||||
expect(stream().scrollTop).toBe(120);
|
||||
});
|
||||
|
||||
it("restores after entries load and clears positions on project disconnect", async () => {
|
||||
useAgents.getState().scrollPositions.set("A", { top: 230, stick: false });
|
||||
useAgents.setState({ entries: new Map() });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await waitFor(() => expect(screen.getByText("Answer A")).toBeInTheDocument());
|
||||
expect(stream().scrollTop).toBe(230);
|
||||
act(() => disconnect());
|
||||
expect(useAgents.getState().scrollPositions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -15,13 +15,14 @@ import type {
|
||||
import { useProject } from "@/platform/project";
|
||||
import { defaultTaskProject } from "./project";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { editorService, notifications, toolWindowService } from "@/workbench/contributions";
|
||||
import { bypassDefaults, canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
|
||||
import { withQuote } from "./conversation";
|
||||
import {
|
||||
HOME_KEY,
|
||||
HOME_KIND,
|
||||
SESSION_KIND,
|
||||
TOOL_WINDOW,
|
||||
attentionChange,
|
||||
entriesKey,
|
||||
isBusy,
|
||||
@@ -53,6 +54,8 @@ interface AgentsState {
|
||||
/** Entries of the tasks (and subagents) opened in this window, by `entriesKey`. */
|
||||
entries: Map<string, AgentEntry[]>;
|
||||
changes: Map<string, AgentChange[]>;
|
||||
/** Scroll position and follow-output preference by conversation, shared by both hosts. */
|
||||
scrollPositions: Map<string, { top: number; stick: boolean }>;
|
||||
providers: AgentProvider[];
|
||||
draft: Draft;
|
||||
composers: Map<string, ComposerDraft>;
|
||||
@@ -62,6 +65,8 @@ interface AgentsState {
|
||||
history: string[];
|
||||
/** The composer asked to take focus (`seq` tells repeated requests apart). */
|
||||
focus: { key: string; seq: number } | null;
|
||||
/** Task shown inside the Agents tool window instead of an editor tab. */
|
||||
docked: string | null;
|
||||
}
|
||||
|
||||
const DRAFT_KEY = "openidea.agents.draft.v2";
|
||||
@@ -100,6 +105,8 @@ export const useAgents = create<AgentsState>(() => ({
|
||||
lastComposer: HOME,
|
||||
history: load<string[]>(HISTORY_KEY, []),
|
||||
focus: null,
|
||||
docked: null,
|
||||
scrollPositions: new Map(),
|
||||
}));
|
||||
|
||||
export function updateDraft(patch: Partial<Draft>) {
|
||||
@@ -202,6 +209,8 @@ export function disconnect() {
|
||||
unread: new Set(),
|
||||
entries: new Map(),
|
||||
changes: new Map(),
|
||||
docked: null,
|
||||
scrollPositions: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,7 +236,7 @@ function onEvent(e: AgentEvent) {
|
||||
composers.delete(e.id);
|
||||
const unread = new Set(s.unread);
|
||||
unread.delete(e.id);
|
||||
useAgents.setState({ tasks, composers, unread });
|
||||
useAgents.setState({ tasks, composers, unread, ...(s.docked === e.id ? { docked: null } : {}) });
|
||||
void editorService().closeInput?.(sessionKey(e.id));
|
||||
break;
|
||||
}
|
||||
@@ -349,6 +358,21 @@ export function openTask(task: string) {
|
||||
void editorService().openInput({ kind: SESSION_KIND, key: sessionKey(task), data: { task, title } }, { focus: true });
|
||||
}
|
||||
|
||||
/** Shows the conversation inside the Agents tool window, closing its editor tab. */
|
||||
export function dockTask(task: string) {
|
||||
useAgents.setState({ docked: task });
|
||||
toolWindowService().show(TOOL_WINDOW, true);
|
||||
void editorService().closeInput?.(sessionKey(task));
|
||||
}
|
||||
|
||||
/** Moves the docked conversation back to an editor tab. */
|
||||
export function undockTask() {
|
||||
const docked = useAgents.getState().docked;
|
||||
if (!docked) return;
|
||||
useAgents.setState({ docked: null });
|
||||
openTask(docked);
|
||||
}
|
||||
|
||||
/** Opens the New Task page; `prompt` pre-fills it. */
|
||||
export function openHome(prompt?: string) {
|
||||
const project = useProject.getState().project;
|
||||
|
||||
@@ -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, unarchiveTask, deleteTask, forkTask, openTask, useAgents } from "./store";
|
||||
import { archiveTask, unarchiveTask, deleteTask, dockTask, forkTask, openTask, undockTask, useAgents } from "./store";
|
||||
|
||||
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
|
||||
const canFork = (task: AgentTask) => canArchive(task) && !!task.capabilities?.fork;
|
||||
@@ -68,6 +68,22 @@ export const taskActions: Action[] = [
|
||||
if (typeof arg === "string") openTask(arg);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agents.dockTask",
|
||||
title: "Open Agent Task in Tool Window",
|
||||
category: "Agents",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
if (typeof arg === "string") dockTask(arg);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agents.undockTask",
|
||||
title: "Open Agent Task in Editor",
|
||||
category: "Agents",
|
||||
hidden: true,
|
||||
run: () => undockTask(),
|
||||
},
|
||||
{
|
||||
id: "agents.forkTask",
|
||||
title: "Copy Agent Task to a New Task",
|
||||
@@ -115,10 +131,20 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] {
|
||||
const multiple = tasks.length > 1;
|
||||
const archived = tasks.filter((task) => task.status === "archived").length;
|
||||
const archivable = tasks.filter(canArchive).length;
|
||||
const docked = useAgents.getState().docked;
|
||||
return [
|
||||
...(!multiple
|
||||
? ([
|
||||
{ kind: "action", action: "agents.openTask", label: "Open", arg: task.id },
|
||||
docked === task.id
|
||||
? { kind: "action", action: "agents.undockTask", label: "Open in Editor", icon: "editor-only" }
|
||||
: {
|
||||
kind: "action",
|
||||
action: "agents.dockTask",
|
||||
label: "Open in Tool Window",
|
||||
icon: "open-in-tool-window",
|
||||
arg: task.id,
|
||||
},
|
||||
{ kind: "action", action: "terminal.openAt", label: "Open in Terminal", icon: "terminal", arg: task.cwd },
|
||||
...(task.capabilities?.fork && task.status !== "archived"
|
||||
? ([
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { AgentEntry, AgentEvent, AgentTask } from "@/bindings";
|
||||
import { useContributions } from "@/workbench/contributions";
|
||||
import { dropOnEditor, useContributions } from "@/workbench/contributions";
|
||||
import { AgentsView } from "./AgentsView";
|
||||
import { AgentsStatus } from "./AgentsStatus";
|
||||
import { activate } from "./index";
|
||||
import { connect, disconnect, markVisible, useAgents } from "./store";
|
||||
import * as actions from "@/platform/actions";
|
||||
import { TASK_DRAG } from "./model";
|
||||
import { connect, disconnect, dockTask, markVisible, undockTask, useAgents } from "./store";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
channel: null as { onmessage: (event: AgentEvent) => void } | null,
|
||||
@@ -13,6 +15,7 @@ const backend = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
openInput: vi.fn(),
|
||||
closeInput: vi.fn(),
|
||||
showToolWindow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", async (original) => ({
|
||||
@@ -36,6 +39,7 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
vi.mock("@/workbench/contributions", async (original) => ({
|
||||
...(await original<typeof import("@/workbench/contributions")>()),
|
||||
editorService: () => ({ openInput: backend.openInput, closeInput: backend.closeInput }),
|
||||
toolWindowService: () => ({ show: backend.showToolWindow }),
|
||||
notifications: () => ({ notify: backend.notify }),
|
||||
}));
|
||||
|
||||
@@ -222,3 +226,52 @@ describe("agent unread indicators", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("docking a conversation in the tool window", () => {
|
||||
it("shows the task in the tool window, closing its editor tab, and undocks back to one", async () => {
|
||||
act(() => dockTask("one"));
|
||||
expect(useAgents.getState().docked).toBe("one");
|
||||
expect(backend.showToolWindow).toHaveBeenCalledWith("agents", true);
|
||||
expect(backend.closeInput).toHaveBeenCalledWith("agent:one");
|
||||
act(() => undockTask());
|
||||
expect(useAgents.getState().docked).toBeNull();
|
||||
expect(backend.openInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "agent-session", key: "agent:one" }),
|
||||
{ focus: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("undocks a removed task", async () => {
|
||||
act(() => dockTask("one"));
|
||||
emit({ type: "removed", id: "one" });
|
||||
expect(useAgents.getState().docked).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a dragged task into the editor: undocking when docked, opening a tab otherwise", () => {
|
||||
const dispose = activate();
|
||||
const execute = vi.spyOn(actions, "executeAction");
|
||||
act(() => dropOnEditor({ type: TASK_DRAG, payload: "two" }));
|
||||
expect(execute).toHaveBeenCalledWith("agents.openTask", "two");
|
||||
expect(useAgents.getState().docked).toBeNull();
|
||||
expect(backend.openInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "agent-session", key: "agent:two" }),
|
||||
{ focus: true },
|
||||
);
|
||||
backend.openInput.mockClear();
|
||||
act(() => dockTask("one"));
|
||||
act(() => dropOnEditor({ type: TASK_DRAG, payload: "one" }));
|
||||
expect(execute).toHaveBeenCalledWith("agents.undockTask", "one");
|
||||
expect(useAgents.getState().docked).toBeNull();
|
||||
expect(backend.openInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "agent-session", key: "agent:one" }),
|
||||
{ focus: true },
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("drops the dock when the project changes", async () => {
|
||||
act(() => dockTask("one"));
|
||||
disconnect();
|
||||
expect(useAgents.getState().docked).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1359,10 +1359,12 @@
|
||||
"Open console for tool output": "为工具输出打开控制台",
|
||||
"Open documents": "打开的文档",
|
||||
"Open in Browser": "在浏览器中打开",
|
||||
"Open in Editor": "在编辑器中打开",
|
||||
"Open in Find Tool Window": "在“查找”工具窗口中打开",
|
||||
"Open in Find Window": "在查找窗口中打开",
|
||||
"Open in Opposite Group": "在相对组中打开",
|
||||
"Open in New Window": "在新窗口中打开",
|
||||
"Open in Tool Window": "在工具窗口中打开",
|
||||
"Open in Terminal": "在终端中打开",
|
||||
"Open in editor": "在编辑器中打开",
|
||||
"Open moved files in editor": "在编辑器中打开已移动的文件",
|
||||
@@ -4816,6 +4818,8 @@
|
||||
"Add to .git/info/exclude": "添加到 .git/info/exclude",
|
||||
"Delete Agent Tasks": "删除 Agent 任务",
|
||||
"Open Agent Task": "打开 Agent 任务",
|
||||
"Open Agent Task in Editor": "在编辑器中打开 Agent 任务",
|
||||
"Open Agent Task in Tool Window": "在工具窗口中打开 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 目录",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "react";
|
||||
import type { FileMatches, SearchQuery, RunConfig } from "@/bindings";
|
||||
import type { MenuItem } from "@/ui/Menu";
|
||||
import type { DragData } from "@/ui/pointerDrag";
|
||||
import type { TreeReorder } from "@/ui/Tree";
|
||||
import { create } from "zustand";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
@@ -501,6 +502,9 @@ export interface OpenEditorTab {
|
||||
lastActive: number;
|
||||
}
|
||||
|
||||
/** MIME of a dragged editor tab: the payload is `{ paneId, tabId }` as JSON. */
|
||||
export const EDITOR_TAB_MIME = "application/x-openidea-editor-tab";
|
||||
|
||||
export interface EditorService {
|
||||
openFile(path: string, options?: OpenOptions): Promise<void>;
|
||||
openInput(input: EditorInput, options?: OpenOptions): Promise<void>;
|
||||
@@ -518,6 +522,8 @@ export interface EditorService {
|
||||
openPaths?(): string[];
|
||||
/** Every open tab, files or not, most recently active first (Switcher). */
|
||||
openTabs?(): OpenEditorTab[];
|
||||
/** Input of the tab `tabId` in pane `paneId` (dragging tabs between views), null if there is none. */
|
||||
tabInput?(paneId: string, tabId: string): EditorInput | null;
|
||||
/** Input of the active tab (any kind), if any. */
|
||||
activeInput?(): EditorInput | null;
|
||||
/** Calls `fn` whenever the active tab changes; returns the unsubscribe. */
|
||||
@@ -632,6 +638,40 @@ export const maybeEditorService = (): EditorService | undefined => services.edit
|
||||
/** Creates terminal widgets; provided by the terminal feature. */
|
||||
export const embeddedTerminal = (): EmbeddedTerminalFactory | undefined => services.embeddedTerminal;
|
||||
|
||||
/**
|
||||
* Handles a pointer drag dropped on the editor content: features register handlers for
|
||||
* their own drag types (e.g. an agent task dragged from the tool window) without the
|
||||
* editor depending on them. The editor shows its center drop-zone for accepted types.
|
||||
*/
|
||||
export interface EditorDropHandler {
|
||||
accepts(type: string): boolean;
|
||||
drop(data: DragData): void;
|
||||
}
|
||||
|
||||
const editorDropHandlers: EditorDropHandler[] = [];
|
||||
|
||||
/** Registers a handler for drags dropped on editor content; returns the unregister. */
|
||||
export function registerEditorDropHandler(handler: EditorDropHandler): () => void {
|
||||
editorDropHandlers.push(handler);
|
||||
return () => {
|
||||
const i = editorDropHandlers.indexOf(handler);
|
||||
if (i >= 0) editorDropHandlers.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether any registered handler takes this drag type. */
|
||||
export function acceptsEditorDrop(type: string): boolean {
|
||||
return editorDropHandlers.some((h) => h.accepts(type));
|
||||
}
|
||||
|
||||
/** Hands a drag dropped on editor content to its registered handler, if any. */
|
||||
export function dropOnEditor(data: DragData): boolean {
|
||||
const handler = editorDropHandlers.find((h) => h.accepts(data.type));
|
||||
if (!handler) return false;
|
||||
handler.drop(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Editor-backed results, recomputed on rerun without reading files from disk. */
|
||||
export interface FindResultsSource {
|
||||
title: string;
|
||||
@@ -897,6 +937,8 @@ export interface MenuGroupContext {
|
||||
text?: string;
|
||||
/** Editor menu: 0-based caret line; gutter menu: the clicked line. */
|
||||
line?: number;
|
||||
/** The tab's input, when the menu is for an editor tab without a path. */
|
||||
input?: EditorInput;
|
||||
}
|
||||
|
||||
export interface MenuGroupContribution {
|
||||
|
||||
Reference in New Issue
Block a user