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:
arookieofc
2026-11-01 11:31:53 +08:00
committed by GitHub
co-authored by a-rookie-of-C-language
parent fa26ba842c
commit 9847fd2c01
18 changed files with 982 additions and 103 deletions
+119
View File
@@ -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();
});
+9 -1
View File
@@ -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…" },
];
}
+1
View File
@@ -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),
+47 -6
View File
@@ -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];
+22
View File
@@ -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 () => {
+72 -1
View File
@@ -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);
}
}
+130 -6
View File
@@ -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();
}
});
});
+168 -76
View File
@@ -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>
);
}
+7
View File
@@ -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;
+38 -7
View File
@@ -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) => {
+28 -1
View File
@@ -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,
{
+2
View File
@@ -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";
+185
View File
@@ -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);
});
});
+26 -2
View File
@@ -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;
+27 -1
View File
@@ -4,7 +4,7 @@ import { tx } from "@/platform/i18n";
import type { MenuItem } from "@/ui";
import { showError, confirmChecked } from "@/workbench/messages";
import { isBusy } from "./model";
import { archiveTask, 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"
? ([
+55 -2
View File
@@ -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();
});
});
+4
View File
@@ -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 目录",
+42
View File
@@ -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 {