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 <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-11-01 11:31:53 +08:00
1 джерело fa26ba842c
коміт 9847fd2c01
18 files changed
+982 -103

No files matched your search

+9 -1
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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 () => {