From 9847fd2c01db19bf5f7b7c09b17eb3afc9ef5077 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 11:31:53 +0800 Subject: [PATCH] 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> --- e2e/agentsDock.spec.ts | 119 +++++++++++ src/editor/core/controller.ts | 10 +- src/editor/core/index.ts | 1 + src/editor/core/view/EditorArea.tsx | 53 ++++- src/editor/core/virtual.test.ts | 22 ++ src/features/agents/Agents.module.css | 73 ++++++- src/features/agents/AgentsView.test.tsx | 136 +++++++++++- src/features/agents/AgentsView.tsx | 244 +++++++++++++++------- src/features/agents/Session.module.css | 7 + src/features/agents/SessionEditor.tsx | 45 +++- src/features/agents/index.ts | 29 ++- src/features/agents/model.ts | 2 + src/features/agents/sessionState.test.tsx | 185 ++++++++++++++++ src/features/agents/store.ts | 28 ++- src/features/agents/taskActions.ts | 28 ++- src/features/agents/unread.test.tsx | 57 ++++- src/platform/locales/text/zh.json | 4 + src/workbench/contributions.ts | 42 ++++ 18 files changed, 982 insertions(+), 103 deletions(-) create mode 100644 e2e/agentsDock.spec.ts create mode 100644 src/features/agents/sessionState.test.tsx diff --git a/e2e/agentsDock.spec.ts b/e2e/agentsDock.spec.ts new file mode 100644 index 00000000..ab54ff5f --- /dev/null +++ b/e2e/agentsDock.spec.ts @@ -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(); +}); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index abc244eb..be343fbf 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -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 { return this.removeGuarded((s) => { const keys = new Set(); @@ -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…" }, ]; } diff --git a/src/editor/core/index.ts b/src/editor/core/index.ts index 68018d91..3097ffdf 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -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), diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index fd7394dc..bd4b709a 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -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 ; + if (empty && layout.kind === "pane") return ; return (
@@ -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(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 ( +
+ + {over && + ); +} + function LayoutView({ node }: { node: LayoutNode }): ReactNode { if (node.kind === "pane") return ; 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]; diff --git a/src/editor/core/virtual.test.ts b/src/editor/core/virtual.test.ts index ceb24d9e..e4417eb4 100644 --- a/src/editor/core/virtual.test.ts +++ b/src/editor/core/virtual.test.ts @@ -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 | 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 () => { diff --git a/src/features/agents/Agents.module.css b/src/features/agents/Agents.module.css index d4c0a47a..ff72619d 100644 --- a/src/features/agents/Agents.module.css +++ b/src/features/agents/Agents.module.css @@ -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); + } +} diff --git a/src/features/agents/AgentsView.test.tsx b/src/features/agents/AgentsView.test.tsx index 9929b0c8..a8ec9d37 100644 --- a/src/features/agents/AgentsView.test.tsx +++ b/src/features/agents/AgentsView.test.tsx @@ -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: () =>
})); + 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(); + } + }); +}); diff --git a/src/features/agents/AgentsView.tsx b/src/features/agents/AgentsView.tsx index 97b8885c..7dafe617 100644 --- a/src/features/agents/AgentsView.tsx +++ b/src/features/agents/AgentsView.tsx @@ -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>(() => new Set(["g:archived"])); const [selected, setSelected] = useState>(() => { const task = sessionTask(maybeEditorService()?.activeInput?.()); @@ -69,8 +95,29 @@ export function AgentsView() { }), [], ); - const roots = useMemo(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]); + // The docked conversation is already shown above; listing it again is noise. + const roots = useMemo( + () => 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(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 (
@@ -79,80 +126,125 @@ export function AgentsView() { ); if (error) return {error}; 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, task: string) => + startPointerDrag(e, e.currentTarget, { type: TASK_DRAG, payload: task }); return ( -
- - 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" ? ( - - - {n.group.label} +
+
+ {docked && ( +
+
+ dragTask(e, docked)} + > + {dockedTitle} - {n.group.tasks.length} - - ) : ( - - - - {n.task.title} - - {unread.has(n.task.id) && ( - - )} - {project?.workspace && ( - - {basename(n.task.projectRoot ?? project.root)} - - )} - - {providerName(n.task.provider)} - {n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""} - - {age(n.task.updatedMs ?? 0)} - - ) - } - empty={ -
- No agent tasks yet - + +
+
+ +
- } - /> + )} + {/* Docked with nothing else to list: give the whole window to the conversation. */} + {(roots.length > 0 || !docked) && ( +
+ + 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" ? ( + + + {n.group.label} + + {n.group.tasks.length} + + ) : ( + dragTask(e, n.task.id)} + > + + + {n.task.title} + + {unread.has(n.task.id) && ( + + )} + {project?.workspace && ( + + {basename(n.task.projectRoot ?? project.root)} + + )} + + {providerName(n.task.provider)} + {n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""} + + {age(n.task.updatedMs ?? 0)} + + ) + } + empty={ +
+ No agent tasks yet + +
+ } + /> +
+ )} +
); } diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index 9fa61ba0..5b104f18 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -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; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 7e756a17..00d2ae51 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -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 ; +} + +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 ; return (
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 && } @@ -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({ }} > - - {task.title} - + {!docked && ( + + {task.title} + + )} @@ -372,11 +384,29 @@ interface StreamProps { function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, onCloseFind }: StreamProps) { const ref = useRef(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) => { diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts index 84d5ec9d..a126567b 100644 --- a/src/features/agents/index.ts +++ b/src/features/agents/index.ts @@ -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, { diff --git a/src/features/agents/model.ts b/src/features/agents/model.ts index 38ad27ce..25b628d4 100644 --- a/src/features/agents/model.ts +++ b/src/features/agents/model.ts @@ -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"; diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx new file mode 100644 index 00000000..74dae983 --- /dev/null +++ b/src/features/agents/sessionState.test.tsx @@ -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() })); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + 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()), + 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(`.${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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await screen.findByText("Answer B"); + expect(stream().scrollTop).toBe(340); + view.rerender(); + 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(); + await waitFor(() => expect(screen.getByText("Answer A")).toBeInTheDocument()); + expect(stream().scrollTop).toBe(230); + act(() => disconnect()); + expect(useAgents.getState().scrollPositions.size).toBe(0); + }); +}); diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index 459b6c65..776093b0 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -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; changes: Map; + /** Scroll position and follow-output preference by conversation, shared by both hosts. */ + scrollPositions: Map; providers: AgentProvider[]; draft: Draft; composers: Map; @@ -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(() => ({ lastComposer: HOME, history: load(HISTORY_KEY, []), focus: null, + docked: null, + scrollPositions: new Map(), })); export function updateDraft(patch: Partial) { @@ -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; diff --git a/src/features/agents/taskActions.ts b/src/features/agents/taskActions.ts index b846a443..3549cda8 100644 --- a/src/features/agents/taskActions.ts +++ b/src/features/agents/taskActions.ts @@ -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" ? ([ diff --git a/src/features/agents/unread.test.tsx b/src/features/agents/unread.test.tsx index 3c78162e..52dfb049 100644 --- a/src/features/agents/unread.test.tsx +++ b/src/features/agents/unread.test.tsx @@ -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()), 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(); + }); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 9d644f86..daff493a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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 目录", diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 9348884f..74f44beb 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -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; openInput(input: EditorInput, options?: OpenOptions): Promise; @@ -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 {