Files
openidea/e2e/agentsDock.spec.ts
arookieofcanda-rookie-of-C-language 9847fd2c01 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>
2026-11-01 11:31:53 +08:00

120 lines
4.7 KiB
TypeScript

/** 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();
});