fix(agents): make tool window sessions usable in narrow layouts

Цей коміт міститься в:
l0ng-ai committed 2026-11-01 18:00:54 +08:00
1 джерело 3a87c697b2
коміт aa2c24c648
17 files changed
+819 -307

No files matched your search

+22 -22
Переглянути файл
@@ -3,7 +3,6 @@
min-height: 0;
display: flex;
flex-direction: column;
container-type: inline-size;
}
.content {
flex: 1;
@@ -137,7 +136,7 @@
gap: 5px;
}
/* A conversation docked in the tool window, above the task list. */
/* A docked conversation owns the tool window; history opens on demand. */
.docked {
flex: 1 1 0;
min-height: 0;
@@ -168,30 +167,31 @@
flex-direction: column;
}
.tasks {
flex: 2 1 0;
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
border-top: 1px solid var(--border);
}
.history {
display: flex;
flex-direction: column;
width: min(400px, calc(100vw - 24px));
height: min(420px, 65vh);
overflow: hidden;
}
.historyTitle {
flex: none;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
font-weight: 600;
}
/* 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);
}
.location {
display: flex;
align-items: center;
gap: 2px;
padding: 1px;
border: 1px solid var(--border);
border-radius: var(--radius-small);
}
+48 -29
Переглянути файл
@@ -6,9 +6,9 @@ import { registerActions } from "@/platform/actions";
import { EDITOR_TAB_MIME, type EditorInput } from "@/workbench/contributions";
import { confirmChecked, showError } from "@/workbench/messages";
import { ContextMenuHost, dragSession, hideContextMenu } from "@/ui";
import { AgentsView } from "./AgentsView";
import { AgentsHeaderActions, AgentsLocationActions, AgentsView } from "./AgentsView";
import { SESSION_KIND } from "./model";
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, undockTask, useAgents } from "./store";
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, setSessionLocation, useAgents } from "./store";
import { taskActions, taskMenu } from "./taskActions";
import styles from "./Agents.module.css";
@@ -39,7 +39,7 @@ vi.mock("./store", async (original) => ({
unarchiveTask: vi.fn(async () => {}),
deleteTask: vi.fn(async () => {}),
dockTask: vi.fn(),
undockTask: vi.fn(),
setSessionLocation: vi.fn(),
}));
vi.mock("./SessionEditor", () => ({ SessionEditor: () => <div data-testid="docked-session" /> }));
@@ -103,6 +103,8 @@ beforeEach(() => {
error: null,
tasks: new Map([task("one"), task("two"), task("three"), task("four")].map((t) => [t.id, t])),
docked: null,
homeOpen: false,
sessionLocation: "editor",
});
dispose = registerActions(taskActions);
});
@@ -115,6 +117,8 @@ afterEach(() => {
function setup() {
render(
<>
<AgentsLocationActions />
<AgentsHeaderActions />
<AgentsView />
<ContextMenuHost />
</>,
@@ -158,24 +162,44 @@ describe("Agents selection", () => {
});
describe("Agents docked view", () => {
it("shows the docked conversation above the list and switches it by selecting a task", () => {
it("opens session history on demand and closes it after switching tasks", () => {
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(screen.getByRole("button", { name: "Chat in Editor" })).toBeTruthy();
expect(openTask).not.toHaveBeenCalled();
expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
fireEvent.click(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("two"));
expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull();
expect(actions.executeAction).toHaveBeenCalledWith("agents.openTask", "two");
expect(openTask).toHaveBeenCalledWith("two");
});
it("hides the docked task from the list below", () => {
it("lets keyboard users browse history before confirming a session", () => {
useAgents.setState({ docked: "one" });
setup();
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
const tree = screen.getByRole("tree", { name: "Agent tasks" });
fireEvent.keyDown(tree, { key: "ArrowDown" });
expect(screen.getByRole("dialog", { name: "Agent sessions" })).toBeTruthy();
expect(openTask).not.toHaveBeenCalled();
fireEvent.keyDown(tree, { key: "Enter" });
expect(openTask).toHaveBeenCalledWith("two");
expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull();
});
it("includes the current session in history and keeps the conversation mounted", () => {
useAgents.setState({ docked: "one" });
setup();
const session = screen.getByTestId("docked-session");
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
const tree = within(screen.getByRole("tree", { name: "Agent tasks" }));
expect(tree.queryByText("one")).toBeNull();
expect(tree.getByText("one")).toBeTruthy();
expect(tree.getByText("two")).toBeTruthy();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull();
expect(screen.getByTestId("docked-session")).toBe(session);
});
it("gives the whole tool window to the conversation when the docked task is the only one", () => {
@@ -185,11 +209,15 @@ describe("Agents docked view", () => {
expect(screen.getByTestId("docked-session")).toBeTruthy();
});
it("undocks from the docked conversation's bar", () => {
useAgents.setState({ docked: "one" });
it("switches placement from the global Agents header", () => {
useAgents.setState({ docked: "one", sessionLocation: "toolWindow" });
setup();
fireEvent.click(screen.getByRole("button", { name: "Open in Editor" }));
expect(undockTask).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "Open in Editor" })).toBeNull();
expect(screen.getByRole("button", { name: "Chat in Tool Window" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Chat in Editor" }));
expect(setSessionLocation).toHaveBeenCalledWith("editor");
fireEvent.click(screen.getByRole("button", { name: "Chat in Tool Window" }));
expect(setSessionLocation).toHaveBeenCalledWith("toolWindow");
});
it("opens tabs as usual without a docked conversation", () => {
@@ -282,21 +310,12 @@ describe("Agents bulk actions", () => {
});
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.
it.each([null, "one"])("keeps placement out of session menus (docked: %s)", (docked) => {
useAgents.setState({ docked });
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));
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.dockTask" }));
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.undockTask" }));
expect(menu).toContainEqual(expect.objectContaining({ action: "agents.openTask" }));
});
});
+153 -106
Переглянути файл
@@ -7,6 +7,7 @@ import {
EmptyState,
Icon,
IconButton,
Popup,
Spinner,
T,
Tree,
@@ -18,16 +19,29 @@ import { SESSION_KIND, TASK_DRAG, age, branchLabel, groupTasks, sessionKey, type
import { openHome, useAgents } from "./store";
import { taskMenu } from "./taskActions";
import { SessionEditor } from "./SessionEditor";
import { HomeEditor } from "./HomeEditor";
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 <IconButton icon="plus" action="agents.newTask" size="small" />;
}
export function AgentsLocationActions() {
const location = useAgents((s) => s.sessionLocation);
const tx = useTx();
return (
<>
<IconButton icon="plus" action="agents.newTask" size="small" />
</>
<div role="group" aria-label={tx("Conversation location")} className={styles.location}>
<IconButton
icon="open-in-tool-window"
action="agents.useToolWindow"
pressed={location === "toolWindow"}
size="small"
/>
<IconButton icon="editor-only" action="agents.useEditor" pressed={location === "editor"} size="small" />
</div>
);
}
@@ -77,6 +91,10 @@ export function AgentsView() {
const unread = useAgents((s) => s.unread);
const providers = useAgents((s) => s.providers);
const docked = useAgents((s) => s.docked);
const homeOpen = useAgents((s) => s.homeOpen);
const inline = docked !== null || homeOpen;
const historyRef = useRef<HTMLButtonElement>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["g:archived"]));
const [selected, setSelected] = useState<Set<string>>(() => {
const task = sessionTask(maybeEditorService()?.activeInput?.());
@@ -95,11 +113,7 @@ export function AgentsView() {
}),
[],
);
// 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 roots = useMemo<Node[]>(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]);
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);
@@ -126,123 +140,156 @@ 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);
// Opening any session follows the global placement preference.
const showTask = (task: string) => {
setHistoryOpen(false);
void executeAction("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 });
const taskList = (
<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 (!inline && 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)}
onClick={(e) => {
if (inline && !e.metaKey && !e.ctrlKey && !e.shiftKey) showTask(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>
);
return (
<div ref={viewRef} className={clsx(styles.view, tabOver && styles.dropTarget)}>
<div className={styles.content}>
{docked && (
{inline && (
<div className={styles.docked}>
<div className={styles.dockedBar}>
<IconButton
ref={historyRef}
icon="history"
title="Agent sessions"
size="small"
aria-haspopup="dialog"
aria-expanded={historyOpen}
onClick={() => {
setSelected(new Set(docked ? [docked] : []));
setHistoryOpen((open) => !open);
}}
/>
<span
className={styles.dockedTitle}
title={dockedTitle ?? undefined}
onPointerDown={(e) => dragTask(e, docked)}
onPointerDown={(e) => docked && dragTask(e, docked)}
>
{dockedTitle}
{homeOpen ? <T>New Agent Task</T> : dockedTitle}
</span>
<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 },
}}
/>
{homeOpen ? (
<HomeEditor docked />
) : (
docked && (
<SessionEditor
docked
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>
{!inline && taskList}
{inline && (
<Popup
open={historyOpen}
anchor={historyRef}
onClose={() => setHistoryOpen(false)}
aria-label={tx("Agent sessions")}
className={styles.history}
>
<div className={styles.historyTitle}>
<T>Agent sessions</T>
</div>
{taskList}
</Popup>
)}
</div>
</div>
+78
Переглянути файл
@@ -1,5 +1,7 @@
/* A framed text area (IDEA text field frame), with the pickers in its bottom row. */
.composer {
container: agent-composer / inline-size;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
@@ -44,9 +46,85 @@
padding: 4px 6px 6px;
cursor: default;
}
.toolbar {
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: center;
min-width: 0;
gap: 2px;
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: 2px;
margin-left: auto;
}
.spacer {
flex: 1;
}
.configPicker {
display: flex;
min-width: 0;
}
.configPicker > button {
max-width: 100%;
}
.settingsMenu {
display: none;
}
.settingsPopup {
width: min(320px, calc(100vw - 24px));
padding: 12px;
}
.settingsTitle {
margin-bottom: 12px;
font-weight: 600;
}
.settingsRow {
display: grid;
grid-template-columns: minmax(0, 80px) minmax(0, 1fr);
align-items: center;
gap: 12px;
margin-top: 8px;
}
.settingsLabel {
min-width: 0;
color: var(--fg-muted);
overflow-wrap: anywhere;
}
@container agent-composer (max-width: 600px) {
.bar {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.toolbar {
flex-wrap: nowrap;
}
.toolbar > button,
.toolbar > .configPicker {
flex: 0 1 auto;
min-width: 0;
max-width: calc((100% - 28px) / 2);
}
.configPicker > button {
width: 100%;
}
.inlineSetting {
display: none;
}
.settingsMenu {
display: flex;
flex: none;
margin-left: auto;
}
.actions {
width: 100%;
margin-left: 0;
}
}
.pickerValue {
display: inline-flex;
align-items: baseline;
+31 -24
Переглянути файл
@@ -403,30 +403,37 @@ export function Composer(props: ComposerProps) {
}}
/>
<div className={styles.bar} onClick={(e) => e.stopPropagation()}>
{props.toolbar}
<span className={styles.spacer} />
<IconButton
icon="file"
title="Reference a File (@)"
size="small"
onClick={() => {
const glue = text && !/\s$/.test(text) ? " " : "";
setText(`${text}${glue}@`, text.length + glue.length + 1);
}}
/>
<IconButton icon="file-image" title="Attach Image…" size="small" onClick={() => imageInput.current?.click()} />
<input
ref={imageInput}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
hidden
onChange={(e) => {
readImageFiles(e.target.files ?? []);
e.target.value = "";
}}
/>
{props.actions}
<div className={styles.toolbar}>{props.toolbar}</div>
<div className={styles.actions}>
<IconButton
icon="file"
title="Reference a File (@)"
size="small"
onClick={() => {
const glue = text && !/\s$/.test(text) ? " " : "";
setText(`${text}${glue}@`, text.length + glue.length + 1);
}}
/>
<IconButton
icon="file-image"
title="Attach Image…"
size="small"
onClick={() => imageInput.current?.click()}
/>
<input
ref={imageInput}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
hidden
onChange={(e) => {
readImageFiles(e.target.files ?? []);
e.target.value = "";
}}
/>
<span className={styles.spacer} />
{props.actions}
</div>
</div>
</div>
);
+26 -1
Переглянути файл
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { AgentConfigOption } from "@/bindings";
import { ConfigPickers, configPickerOptions } from "./ConfigPickers";
@@ -85,3 +85,28 @@ it("sends the complete nested model id through ConfigPickers", () => {
fireEvent.click(screen.getByRole("option", { name: "Beamcmc/Beam" }));
expect(onChange).toHaveBeenCalledExactlyOnceWith("model", "9router/cmc/Beam");
});
it("edits secondary settings from the compact popup and restores focus when dismissed", () => {
const approval: AgentConfigOption = {
id: "approvals",
name: "Approvals",
category: "approval",
description: null,
current: "ask",
via: "option",
values: [value("ask", "Ask first"), value("bypass", "Bypass approvals")],
};
const onChange = vi.fn();
render(<ConfigPickers config={[approval]} values={{ approvals: "bypass" }} onChange={onChange} />);
const trigger = screen.getByRole("button", { name: "Agent settings" });
trigger.focus();
fireEvent.click(trigger);
const dialog = screen.getByRole("dialog", { name: "Agent settings" });
fireEvent.click(within(dialog).getByRole("button", { name: "Approvals" }));
fireEvent.click(screen.getByRole("option", { name: "Ask first" }));
expect(onChange).toHaveBeenCalledExactlyOnceWith("approvals", "ask");
expect(dialog).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog", { name: "Agent settings" })).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
+94 -39
Переглянути файл
@@ -2,9 +2,11 @@
* Model, mode, approvals and thinking-effort dropdowns for a composer's bottom row.
* Modes and approvals use icons; levels are plain text.
*/
import { useRef, useState } from "react";
import clsx from "clsx";
import type { AgentConfigOption } from "@/bindings";
import { useTx } from "@/platform/i18n";
import { Select } from "@/ui";
import { IconButton, Popup, Select, T } from "@/ui";
import type { SelectOption } from "@/ui/Select";
import { mainOptions, modeIcon } from "./composerModel";
import styles from "./Composer.module.css";
@@ -43,47 +45,100 @@ export function ConfigPickers({
onChange: (option: string, value: string) => void;
}) {
const tx = useTx();
const [open, setOpen] = useState(false);
const anchor = useRef<HTMLButtonElement>(null);
const options = mainOptions(config);
const secondary = options.filter((o) => o.category !== "model");
const approval = secondary.find((o) => o.category === "approval" || o.category === "mode");
const summary = secondary
.map((o) => {
const value = values?.[o.id] ?? o.current;
return `${tx(o.name)}: ${o.values.find((v) => v.value === value)?.name ?? tx("Default")}`;
})
.join(" · ");
return (
<>
{mainOptions(config).map((o) => {
const isMode = o.category === "mode";
const value = values?.[o.id] ?? o.current ?? null;
const options = configPickerOptions(o);
return (
<Select
key={o.id}
variant="ghost"
aria-label={o.name}
value={value}
placeholder={tx(o.name)}
options={options.map((opt) => ({
...opt,
icon:
o.category === "approval"
? opt.value === "bypass"
? "unlock"
: "lock"
: isMode
? modeIcon(opt.value)
: undefined,
}))}
searchable={o.category === "model"}
onChange={(v) => onChange(o.id, v)}
// Effort and other levels say what they are: "Effort Medium".
renderValue={
o.category === "thought_level"
? (current) => (
<span className={styles.pickerValue}>
<span className={styles.pickerName}>{o.name}</span>
{current?.label ?? tx("Default")}
</span>
)
: undefined
}
{options.map((o) => (
<span key={o.id} className={clsx(styles.configPicker, o.category !== "model" && styles.inlineSetting)}>
<ConfigPicker option={o} value={values?.[o.id] ?? o.current ?? null} onChange={onChange} />
</span>
))}
{secondary.length > 0 && (
<div className={styles.settingsMenu}>
<IconButton
ref={anchor}
icon={approval ? modeIcon(values?.[approval.id] ?? approval.current ?? "") : "settings"}
title={`${tx("Agent settings")}: ${summary}`}
aria-label={tx("Agent settings")}
aria-haspopup="dialog"
aria-expanded={open}
size="small"
onClick={() => setOpen((v) => !v)}
/>
);
})}
<Popup
open={open}
anchor={anchor}
onClose={() => setOpen(false)}
aria-label={tx("Agent settings")}
className={styles.settingsPopup}
>
<div className={styles.settingsTitle}>
<T>Agent settings</T>
</div>
{secondary.map((o) => (
<div key={o.id} className={styles.settingsRow}>
<span className={styles.settingsLabel}>{tx(o.name)}</span>
<ConfigPicker option={o} value={values?.[o.id] ?? o.current ?? null} onChange={onChange} framed />
</div>
))}
</Popup>
</div>
)}
</>
);
}
function ConfigPicker({
option,
value,
onChange,
framed = false,
}: {
option: AgentConfigOption;
value: string | null;
onChange: (option: string, value: string) => void;
framed?: boolean;
}) {
const tx = useTx();
return (
<Select
variant={framed ? "framed" : "ghost"}
aria-label={option.name}
value={value}
placeholder={tx(option.name)}
options={configPickerOptions(option).map((opt) => ({
...opt,
icon:
option.category === "approval"
? opt.value === "bypass"
? "unlock"
: "lock"
: option.category === "mode"
? modeIcon(opt.value)
: undefined,
}))}
searchable={option.category === "model"}
onChange={(v) => onChange(option.id, v)}
renderValue={
option.category === "thought_level" && !framed
? (current) => (
<span className={styles.pickerValue}>
<span className={styles.pickerName}>{option.name}</span>
{current?.label ?? tx("Default")}
</span>
)
: undefined
}
/>
);
}
+44
Переглянути файл
@@ -60,3 +60,47 @@
.warning {
color: var(--danger);
}
.docked {
justify-content: flex-end;
padding: 16px 12px 12px;
}
.docked .inner {
gap: 8px;
}
.docked .where {
order: -1;
flex-wrap: nowrap;
min-width: 0;
gap: 6px;
}
.docked .project,
.docked .projectPicker {
flex: 0 1 auto;
min-width: 0;
max-width: 30%;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.docked .branch,
.docked .basePicker {
order: 1;
flex: 1 1 0;
min-width: 0;
padding: 0;
}
.docked .isolation {
order: 2;
flex: 0 1 auto;
min-width: 0;
max-width: 45%;
margin-left: auto;
}
.branchName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+31 -17
Переглянути файл
@@ -1,4 +1,5 @@
/** New Task page: one prompt box with the agent and its settings; where it works under it. */
import clsx from "clsx";
import { useEffect, useState } from "react";
import type { AgentProbe, Branch } from "@/bindings";
import { executeAction } from "@/platform/actions";
@@ -7,7 +8,7 @@ import { useTx, useTxTemplate } from "@/platform/i18n";
import { basename, useProject } from "@/platform/project";
import { Icon, Select, T, type SelectOption } from "@/ui";
import { defaultTaskProject } from "./project";
import { maybeEditorService } from "@/workbench/contributions";
import { maybeEditorService, type EditorInput } from "@/workbench/contributions";
import { Composer } from "./Composer";
import { SubmitButton } from "./SubmitButton";
import { canSend, emptyDraft } from "./composerModel";
@@ -17,7 +18,7 @@ import { ADD_AGENT, providerOptions as agentOptions } from "./providerOptions";
import { HOME, NO_COMMANDS, refreshProviders, startTask, updateDraft, useAgentCommands, useAgents } from "./store";
import styles from "./Home.module.css";
export function HomeEditor() {
export function HomeEditor({ docked = false }: { docked?: boolean; input?: EditorInput }) {
const tx = useTx();
const txTemplate = useTxTemplate();
const draft = useAgents((s) => s.draft);
@@ -86,15 +87,19 @@ export function HomeEditor() {
const setOption = (id: string, value: string) => updateDraft({ config: { ...draft.config, [id]: value } });
return (
<div className={styles.home}>
<div className={clsx(styles.home, docked && styles.docked)}>
<div className={styles.inner}>
<h1 className={styles.heading}>
<T>What should the agent do?</T>
</h1>
{!docked && (
<h1 className={styles.heading}>
<T>What should the agent do?</T>
</h1>
)}
<Composer
id={HOME}
autoFocus
placeholder="Describe a task for the agent. @ references a file, / runs a command"
placeholder={
docked ? "Describe a task…" : "Describe a task for the agent. @ references a file, / runs a command"
}
root={root}
projectRoot={root}
commands={agentCommands}
@@ -131,6 +136,7 @@ export function HomeEditor() {
<Select
variant="ghost"
aria-label={tx("Project")}
className={styles.projectPicker}
value={root}
options={members.map((path) => ({ value: path, label: basename(path), detail: path }))}
onChange={(path) => {
@@ -139,12 +145,15 @@ export function HomeEditor() {
}}
/>
) : (
<span className={styles.project}>{members.length ? basename(root) : project?.name}</span>
<span className={styles.project} title={root}>
{members.length ? basename(root) : project?.name}
</span>
)}
{isGit && (
<Select
variant="ghost"
aria-label="Isolation"
className={styles.isolation}
value={isolation}
options={[
{ value: "worktree", label: tx("New worktree"), icon: "branch", detail: tx("away from your changes") },
@@ -160,23 +169,28 @@ export function HomeEditor() {
)}
{isolation === "local" && current && (
<>
<span className={styles.note}>
<T>on</T>
</span>
<span className={styles.branch}>
{!docked && (
<span className={styles.note}>
<T>on</T>
</span>
)}
<span className={styles.branch} title={current}>
<Icon name="branch" size={14} />
{current}
<span className={styles.branchName}>{current}</span>
</span>
</>
)}
{isolation === "worktree" && branches.length > 0 && (
<>
<span className={styles.note}>
<T>from</T>
</span>
{!docked && (
<span className={styles.note}>
<T>from</T>
</span>
)}
<Select
variant="ghost"
aria-label="Base branch"
className={styles.basePicker}
value={base}
options={branches.map((b) => ({ value: b.name, label: b.name }))}
onChange={(b) => updateDraft({ base: b === current ? null : b })}
@@ -184,7 +198,7 @@ export function HomeEditor() {
</>
)}
</div>
{probe && (
{probe && (!docked || probe.error) && (
<p className={probe.error ? styles.warning : styles.note}>
{probe.error
? txTemplate(`${provider?.name ?? probe.provider} didn't start: ${probe.error}`)
+25 -1
Переглянути файл
@@ -16,6 +16,20 @@
border-right: 1px solid var(--border);
}
.conversation[hidden] {
display: none;
}
.docked {
grid-template-columns: minmax(0, 1fr);
overflow: hidden;
}
.docked .conversation {
border-right: 0;
}
.docked .changes {
width: auto;
}
/* The conversation hugs its content; spare space stays below the composer
instead of inside the chat. */
.streamArea,
@@ -568,6 +582,7 @@
width: 280px;
}
.changesHeader {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
@@ -577,11 +592,17 @@
border-bottom: 1px solid var(--border);
}
.changesTitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
color: var(--fg-strong);
}
.changesSummary {
flex: none;
overflow: hidden;
text-overflow: ellipsis;
padding: 4px 10px;
color: var(--fg-subtle);
white-space: pre;
@@ -608,8 +629,11 @@
gap: 4px;
}
.changeName {
flex: none;
flex: 0 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.changeDir {
flex: 1 1 0;
+30 -14
Переглянути файл
@@ -68,15 +68,13 @@ import { setFocusedSession, focusedSession, type FocusedSession } from "./sessio
import { taskMenu } from "./taskActions";
import styles from "./Session.module.css";
export function SessionEditor({ input }: { input: EditorInput }) {
export function SessionEditor({ input, docked = false }: { input: EditorInput; docked?: boolean }) {
const id = (input.data as { task: string }).task;
return <TaskSession key={id} id={id} />;
return <TaskSession key={id} id={id} docked={docked} />;
}
function TaskSession({ id }: { id: string }) {
function TaskSession({ id, docked }: { id: string; docked: boolean }) {
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);
@@ -84,7 +82,7 @@ function TaskSession({ id }: { id: string }) {
task?.isolation === "local" ? task.status : null,
project?.workspace ? (task?.projectRoot ?? project.root) : null,
);
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null);
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null, docked);
// The subagent conversation shown, with the subagents that lead to it (empty: the task's own).
const [path, setPath] = useState<string[]>([]);
const view = path.at(-1) ?? null;
@@ -96,8 +94,10 @@ function TaskSession({ id }: { id: string }) {
useEffect(() => {
void loadEntries(id).catch(() => undefined);
void refreshChanges(id);
return markVisible(id);
}, [id]);
useEffect(() => {
if (!docked || !changesOpen) return markVisible(id);
}, [id, docked, changesOpen]);
useEffect(() => {
if (view) void loadEntries(id, view).catch(() => undefined);
}, [id, view]);
@@ -127,7 +127,7 @@ function TaskSession({ id }: { id: string }) {
}
}}
>
<div className={styles.conversation}>
<div className={styles.conversation} hidden={docked && changesOpen}>
<Header
task={task}
entries={entries ?? null}
@@ -158,7 +158,13 @@ function TaskSession({ id }: { id: string }) {
)}
</div>
{changesOpen && (
<ChangesPane task={task} changes={changes ?? null} head={head} onHide={() => setChangesOpen(false)} />
<ChangesPane
task={task}
changes={changes ?? null}
head={head}
docked={docked}
onHide={() => setChangesOpen(false)}
/>
)}
</div>
);
@@ -270,6 +276,7 @@ function Header({
className={styles.changesToggle}
aria-pressed={changesOpen}
disabled={count === 0 && !changesOpen}
aria-label={changesOpen ? tx("Hide changes") : tx("Show changes")}
title={changesOpen ? tx("Hide changes") : tx("Show changes")}
onClick={onToggleChanges}
>
@@ -1292,16 +1299,22 @@ function loadChangesOpen(): Record<string, boolean> {
/**
* Whether the Changes pane is shown for `task`: the user's choice once made, else
* only while the task has changes (so it opens when the agent first edits a file).
* only while the task has changes. Docked sessions stay in the conversation until
* the user explicitly opens changes; navigation there does not change editor preferences.
*/
function useChangesOpen(task: string, changes: AgentChange[] | null): [boolean, (open: boolean) => void] {
function useChangesOpen(
task: string,
changes: AgentChange[] | null,
docked: boolean,
): [boolean, (open: boolean) => void] {
const [state, setState] = useState<{ task: string; open: boolean | undefined }>(() => ({
task,
open: loadChangesOpen()[task],
open: docked ? false : loadChangesOpen()[task],
}));
const chosen = state.task === task ? state.open : loadChangesOpen()[task];
const chosen = state.task === task ? state.open : docked ? false : loadChangesOpen()[task];
const set = (open: boolean) => {
setState({ task, open });
if (docked) return;
try {
globalThis.localStorage?.setItem(CHANGES_OPEN_KEY, JSON.stringify({ ...loadChangesOpen(), [task]: open }));
} catch {
@@ -1315,11 +1328,13 @@ function ChangesPane({
task,
changes,
head,
docked,
onHide,
}: {
task: AgentTask;
changes: AgentChange[] | null;
head: string | null;
docked: boolean;
onHide: () => void;
}) {
const [selected, setSelected] = useState<Set<string>>(() => new Set());
@@ -1328,6 +1343,7 @@ function ChangesPane({
return (
<aside className={styles.changes} aria-label={tx("Changes")}>
<div className={styles.changesHeader}>
{docked && <IconButton icon="arrow-left" title="Back to conversation" size="small" onClick={onHide} />}
<span className={styles.changesTitle}>
<T>Changes</T>
</span>
@@ -1338,7 +1354,7 @@ function ChangesPane({
)}
<span className={styles.bannerSpacer} />
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshChanges(task.id)} />
<IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />
{!docked && <IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />}
</div>
{(head ?? task.base) && (
<div className={styles.changesSummary}>
+3 -23
Переглянути файл
@@ -54,6 +54,7 @@ export function activate(): () => void {
anchor: "right",
order: 0.5,
component: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsView)),
headerContent: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsLocationActions)),
headerActions: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsHeaderActions)),
useBadge: () => {
const n = useAgents(
@@ -80,11 +81,10 @@ 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).
// An explicit editor drop sets the placement for subsequent sessions too.
registerEditorDropHandler({
accepts: (type) => type === TASK_DRAG,
drop: ({ payload }) =>
void executeAction(useAgents.getState().docked === payload ? "agents.undockTask" : "agents.openTask", payload),
drop: ({ payload }) => void executeAction("agents.openTaskInEditor", payload),
}),
registerStatusItem({ id: "agents.status", align: "right", priority: 3, title: "Agents", component: AgentsStatus }),
registerMenuGroup({
@@ -96,26 +96,6 @@ 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,
{
+50 -6
Переглянути файл
@@ -1,7 +1,7 @@
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 type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
import { AgentsView } from "./AgentsView";
import { SessionEditor } from "./SessionEditor";
import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./model";
@@ -9,7 +9,7 @@ import { disconnect, useAgents } from "./store";
import { taskActions } from "./taskActions";
import styles from "./Session.module.css";
const backend = vi.hoisted(() => ({ entries: new Map<string, AgentEntry[]>() }));
const backend = vi.hoisted(() => ({ entries: new Map<string, AgentEntry[]>(), changes: [] as AgentChange[] }));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
commands: {
@@ -17,7 +17,7 @@ vi.mock("@/platform/ipc", async (original) => ({
status: "ok",
data: backend.entries.get(sub ? `${id}/${sub}` : id) ?? [],
}),
agentChanges: async () => ({ status: "ok", data: [] }),
agentChanges: async () => ({ status: "ok", data: backend.changes }),
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
},
}));
@@ -76,6 +76,8 @@ afterEach(() => {
beforeEach(() => {
disconnect();
localStorage.clear();
backend.changes = [];
backend.entries = new Map([
["A", [{ type: "agent", text: "Answer A" }, subagent]],
[entriesKey("A", "child"), [{ type: "agent", text: "Child answer" }]],
@@ -83,6 +85,8 @@ beforeEach(() => {
]);
useAgents.setState({
loaded: true,
sessionLocation: "editor",
homeOpen: false,
tasks: new Map([task("A"), task("B")].map((t) => [t.id, t])),
entries: new Map(backend.entries),
});
@@ -92,12 +96,13 @@ 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" });
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
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 });
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
fireEvent.click(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("B"));
expect(await screen.findByText("Answer B")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Message" })).toBeInTheDocument();
expect(screen.queryByText("Child answer")).not.toBeInTheDocument();
@@ -110,6 +115,45 @@ describe("session state across tasks and hosts", () => {
}
});
it("keeps docked changes opt-in, preserves the draft, and returns to the same conversation", async () => {
backend.changes = [
{
path: "/project/very-long-file-name.ts",
relPath: "very-long-file-name.ts",
kind: "modified",
added: 8,
removed: 2,
},
];
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
render(<AgentsView />);
await screen.findByText("Answer A");
await waitFor(() => expect(screen.getByRole("button", { name: "Show changes" })).toBeEnabled());
expect(screen.queryByRole("complementary", { name: "Changes" })).toBeNull();
const markVisible = vi.spyOn(await import("./store"), "markVisible");
vi.spyOn(document, "hasFocus").mockReturnValue(true);
const message = screen.getByRole("textbox", { name: "Message" });
fireEvent.change(message, { target: { value: "Draft to keep" } });
const conversation = stream();
fireEvent.click(screen.getByRole("button", { name: "Show changes" }));
expect(screen.getByRole("complementary", { name: "Changes" })).toBeVisible();
expect(screen.getByText("Answer A")).not.toBeVisible();
expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
act(() => useAgents.setState({ unread: new Set(["A"]) }));
fireEvent(window, new Event("focus"));
expect(useAgents.getState().unread.has("A")).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Back to conversation" }));
expect(screen.getByText("Answer A")).toBeVisible();
expect(stream()).toBe(conversation);
expect(markVisible).toHaveBeenCalledWith("A");
expect(useAgents.getState().unread.has("A")).toBe(false);
expect(screen.getByRole("textbox", { name: "Message" })).toBe(message);
expect(message).toHaveValue("Draft to keep");
expect(useAgents.getState().composers.get("A")?.text).toBe("Draft to keep");
expect(JSON.parse(localStorage.getItem("openidea.agents.changesOpen.v1")!).A).toBe(true);
});
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;
@@ -133,7 +177,7 @@ describe("session state across tasks and hosts", () => {
expect(useAgents.getState().scrollPositions.get("A")).toEqual({ top: stick ? 1600 : 230, stick });
view.unmount();
viewport = 150;
useAgents.setState({ docked: "A" });
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
const dock = render(<AgentsView />);
await screen.findByText("Answer A");
expect(stream().scrollTop).toBe(stick ? height - viewport : 230);
+77 -8
Переглянути файл
@@ -44,6 +44,8 @@ export interface Draft {
/** Composer key of the New Task page; sessions use their task id. */
export const HOME = "home";
export type SessionLocation = "editor" | "toolWindow";
interface AgentsState {
loaded: boolean;
/** Why the tasks could not be loaded. */
@@ -67,11 +69,15 @@ interface AgentsState {
focus: { key: string; seq: number } | null;
/** Task shown inside the Agents tool window instead of an editor tab. */
docked: string | null;
sessionLocation: SessionLocation;
lastTask: string | null;
homeOpen: boolean;
}
const DRAFT_KEY = "openidea.agents.draft.v2";
const HISTORY_KEY = "openidea.agents.history.v1";
const HISTORY_SIZE = 50;
const SESSION_LOCATION_KEY = "openidea.agents.sessionLocation.v1";
function load<T>(key: string, fallback: T): T {
try {
@@ -106,6 +112,9 @@ export const useAgents = create<AgentsState>(() => ({
history: load<string[]>(HISTORY_KEY, []),
focus: null,
docked: null,
sessionLocation: load<unknown>(SESSION_LOCATION_KEY, null) === "toolWindow" ? "toolWindow" : "editor",
lastTask: null,
homeOpen: false,
scrollPositions: new Map(),
}));
@@ -210,6 +219,8 @@ export function disconnect() {
entries: new Map(),
changes: new Map(),
docked: null,
lastTask: null,
homeOpen: false,
scrollPositions: new Map(),
});
}
@@ -353,24 +364,76 @@ export function useAgentCommands(
}
export function openTask(task: string) {
if (useAgents.getState().sessionLocation === "toolWindow") {
showDockedTask(task);
return;
}
useAgents.setState({ lastTask: task, homeOpen: false });
openTaskInEditor(task);
}
function openTaskInEditor(task: string) {
// The title rides along so a restored tab has it before the tasks load.
const title = useAgents.getState().tasks.get(task)?.title;
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 });
function showDockedTask(task: string) {
useAgents.setState({ docked: task, lastTask: task, homeOpen: false });
toolWindowService().show(TOOL_WINDOW, true);
void editorService().closeInput?.(sessionKey(task));
}
/** Moves the docked conversation back to an editor tab. */
function saveSessionLocation(location: SessionLocation) {
useAgents.setState({ sessionLocation: location });
save(SESSION_LOCATION_KEY, location);
if (location === "toolWindow") {
for (const { input } of editorService().openTabs?.() ?? []) {
if (input.kind === SESSION_KIND || input.kind === HOME_KIND) void editorService().closeInput?.(input.key);
}
}
}
/** Conversation placement is an Agents-wide preference, shared by every task. */
export function setSessionLocation(location: SessionLocation) {
const s = useAgents.getState();
if (s.sessionLocation === location) return;
const active = editorService().activeInput?.();
const activeTask = active?.kind === SESSION_KIND ? (active.data as { task: string }).task : null;
const home = s.homeOpen || active?.kind === HOME_KIND;
const task =
s.docked ??
activeTask ??
(s.lastTask && s.tasks.has(s.lastTask) ? s.lastTask : null) ??
[...s.tasks.values()]
.filter((t) => t.status !== "archived")
.sort((a, b) => (b.updatedMs ?? b.createdMs ?? 0) - (a.updatedMs ?? a.createdMs ?? 0))[0]?.id;
saveSessionLocation(location);
useAgents.setState({ docked: null, homeOpen: false });
if (home) {
openHome();
} else if (task) {
openTask(task);
} else if (location === "toolWindow") {
openHome();
}
}
/** Dragging a session into the tool window selects the same placement for other tasks. */
export function dockTask(task: string) {
saveSessionLocation("toolWindow");
showDockedTask(task);
}
export function undockTask() {
const docked = useAgents.getState().docked;
if (!docked) return;
useAgents.setState({ docked: null });
openTask(docked);
setSessionLocation("editor");
}
/** An explicit editor drop also updates where subsequent sessions open. */
export function openTaskInEditorLocation(task: string) {
saveSessionLocation("editor");
useAgents.setState({ docked: null, homeOpen: false });
openTask(task);
}
/** Opens the New Task page; `prompt` pre-fills it. */
@@ -382,6 +445,12 @@ export function openHome(prompt?: string) {
updateDraft({ projectRoot, ...(projectRoot !== previous ? { base: null } : {}) });
}
if (prompt !== undefined) updateComposer(HOME, { text: prompt });
if (useAgents.getState().sessionLocation === "toolWindow") {
useAgents.setState({ docked: null, homeOpen: true });
toolWindowService().show(TOOL_WINDOW, true);
void editorService().closeInput?.(HOME_KEY);
return;
}
void editorService().openInput({ kind: HOME_KIND, key: HOME_KEY, data: {} }, { focus: true });
}
+35 -11
Переглянути файл
@@ -4,7 +4,18 @@ import { tx } from "@/platform/i18n";
import type { MenuItem } from "@/ui";
import { showError, confirmChecked } from "@/workbench/messages";
import { isBusy } from "./model";
import { archiveTask, unarchiveTask, deleteTask, dockTask, forkTask, openTask, undockTask, useAgents } from "./store";
import {
archiveTask,
unarchiveTask,
deleteTask,
dockTask,
forkTask,
openTask,
openTaskInEditorLocation,
setSessionLocation,
undockTask,
useAgents,
} from "./store";
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
const canFork = (task: AgentTask) => canArchive(task) && !!task.capabilities?.fork;
@@ -59,6 +70,29 @@ async function confirmDelete(tasks: AgentTask[]) {
}
export const taskActions: Action[] = [
{
id: "agents.useToolWindow",
title: "Chat in Tool Window",
category: "Agents",
icon: "open-in-tool-window",
run: () => setSessionLocation("toolWindow"),
},
{
id: "agents.useEditor",
title: "Chat in Editor",
category: "Agents",
icon: "editor-only",
run: () => setSessionLocation("editor"),
},
{
id: "agents.openTaskInEditor",
title: "Open Agent Task in Editor",
category: "Agents",
hidden: true,
run: ({ arg }) => {
if (typeof arg === "string") openTaskInEditorLocation(arg);
},
},
{
id: "agents.openTask",
title: "Open Agent Task",
@@ -131,20 +165,10 @@ 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"
? ([
+64 -5
Переглянути файл
@@ -1,15 +1,27 @@
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 { dropOnEditor, useContributions } from "@/workbench/contributions";
import { dropOnEditor, useContributions, type EditorInput, type OpenEditorTab } from "@/workbench/contributions";
import { AgentsView } from "./AgentsView";
import { AgentsStatus } from "./AgentsStatus";
import { activate } from "./index";
import * as actions from "@/platform/actions";
import { TASK_DRAG } from "./model";
import { connect, disconnect, dockTask, markVisible, undockTask, useAgents } from "./store";
import {
connect,
disconnect,
dockTask,
markVisible,
openHome,
openTask,
setSessionLocation,
undockTask,
useAgents,
} from "./store";
const backend = vi.hoisted(() => ({
active: null as EditorInput | null,
tabs: [] as OpenEditorTab[],
channel: null as { onmessage: (event: AgentEvent) => void } | null,
tasks: [] as AgentTask[],
notify: vi.fn(),
@@ -38,7 +50,12 @@ 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 }),
editorService: () => ({
openInput: backend.openInput,
closeInput: backend.closeInput,
activeInput: () => backend.active,
openTabs: () => backend.tabs,
}),
toolWindowService: () => ({ show: backend.showToolWindow }),
notifications: () => ({ notify: backend.notify }),
}));
@@ -81,6 +98,10 @@ beforeEach(async () => {
vi.spyOn(document, "hasFocus").mockReturnValue(true);
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
disconnect();
backend.active = null;
backend.tabs = [];
useAgents.setState({ sessionLocation: "editor" });
localStorage.clear();
backend.tasks = [task("one"), task("two", "ready")];
await connect();
});
@@ -251,7 +272,7 @@ describe("docking a conversation in the tool window", () => {
const dispose = activate();
const execute = vi.spyOn(actions, "executeAction");
act(() => dropOnEditor({ type: TASK_DRAG, payload: "two" }));
expect(execute).toHaveBeenCalledWith("agents.openTask", "two");
expect(execute).toHaveBeenCalledWith("agents.openTaskInEditor", "two");
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenCalledWith(
expect.objectContaining({ kind: "agent-session", key: "agent:two" }),
@@ -260,7 +281,7 @@ describe("docking a conversation in the tool window", () => {
backend.openInput.mockClear();
act(() => dockTask("one"));
act(() => dropOnEditor({ type: TASK_DRAG, payload: "one" }));
expect(execute).toHaveBeenCalledWith("agents.undockTask", "one");
expect(execute).toHaveBeenCalledWith("agents.openTaskInEditor", "one");
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenCalledWith(
expect.objectContaining({ kind: "agent-session", key: "agent:one" }),
@@ -269,9 +290,47 @@ describe("docking a conversation in the tool window", () => {
dispose();
});
it("moves the active session and applies the header choice to every subsequent session", () => {
backend.active = { kind: "agent-session", key: "agent:two", data: { task: "two" } };
backend.tabs = ["one", "two"].map((id) => ({
input: { kind: "agent-session", key: `agent:${id}`, data: { task: id } },
title: id,
icon: "toolwindow-agents",
lastActive: 0,
}));
act(() => setSessionLocation("toolWindow"));
expect(backend.closeInput).toHaveBeenCalledWith("agent:one");
expect(backend.closeInput).toHaveBeenCalledWith("agent:two");
expect(useAgents.getState().docked).toBe("two");
expect(backend.openInput).not.toHaveBeenCalled();
act(() => openTask("one"));
expect(useAgents.getState().docked).toBe("one");
expect(backend.openInput).not.toHaveBeenCalled();
expect(JSON.parse(localStorage.getItem("openidea.agents.sessionLocation.v1")!)).toBe("toolWindow");
act(() => setSessionLocation("editor"));
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenLastCalledWith(expect.objectContaining({ key: "agent:one" }), { focus: true });
act(() => openTask("two"));
expect(backend.openInput).toHaveBeenLastCalledWith(expect.objectContaining({ key: "agent:two" }), { focus: true });
});
it("opens new tasks in the selected location and moves an unfinished draft with the layout", () => {
act(() => setSessionLocation("toolWindow"));
backend.openInput.mockClear();
act(() => openHome("Keep this draft"));
expect(useAgents.getState().homeOpen).toBe(true);
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).not.toHaveBeenCalled();
act(() => setSessionLocation("editor"));
expect(useAgents.getState().homeOpen).toBe(false);
expect(backend.openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: "agent-home" }), { focus: true });
expect(useAgents.getState().composers.get("home")?.text).toBe("Keep this draft");
});
it("drops the dock when the project changes", async () => {
act(() => dockTask("one"));
disconnect();
expect(useAgents.getState().docked).toBeNull();
expect(useAgents.getState().sessionLocation).toBe("toolWindow");
});
});
+8 -1
Переглянути файл
@@ -5440,5 +5440,12 @@
"copy paths must belong to the comparison roots": "复制路径必须位于比较的根目录内",
"cannot copy overlapping paths": "无法在相互包含的路径之间复制",
"cannot copy through a destination symlink": "无法通过目标目录内的符号链接复制",
"expected a relative comparison entry path": "需要比较条目的相对路径"
"expected a relative comparison entry path": "需要比较条目的相对路径",
"Agent sessions": "智能体会话",
"Back to conversation": "返回会话",
"Conversation location": "会话显示位置",
"Chat in Tool Window": "在工具窗口中聊天",
"Chat in Editor": "在编辑器中聊天",
"Agent settings": "智能体设置",
"Describe a task…": "描述一个任务…"
}