дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(agents): make tool window sessions usable in narrow layouts
Цей коміт міститься в:
1 джерело
3a87c697b2
коміт
aa2c24c648
17 files changed
+819
-307
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
? ([
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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…": "描述一个任务…"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача