дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(agents): Show subagents as their own conversations (#132)
* feat(agents): Show subagents as their own conversations Agents that support ACP subagent sessions (Claude Code, Codex) now run each subagent as a child session. Its tool calls and messages no longer mix into the task's conversation: the parent shows a card per subagent with its state, tool call count, latest step, task and result, and the card opens the subagent's own conversation. - Route session/update by session id; subagent settings and usage stay out of the task. - Subagent permission requests are answered in the task's conversation, named after the subagent. - Subagent conversations are saved with the task; a running subagent is marked stopped after a restart. - A running subagent can be stopped on its own. * fix(agents): Save a subagent's state when it changes after the turn
Цей коміт міститься в:
1 джерело
5b63a2b95c
коміт
ecb3134460
14 files changed
+1230
-80
No files matched your search
+30
-5
@@ -61,7 +61,8 @@ export const commands = {
|
||||
* before a task exists (skills and custom commands on disk included).
|
||||
*/
|
||||
agentCommands: (provider: string, task: string | null) => typedError<AgentCommand[], IdeError>(__TAURI_INVOKE("agent_commands", { provider, task })),
|
||||
agentEntries: (task: string) => typedError<AgentEntry[], IdeError>(__TAURI_INVOKE("agent_entries", { task })),
|
||||
/** Entries of `task`'s conversation, or of its subagent `subagent`'s. */
|
||||
agentEntries: (task: string, subagent: string | null) => typedError<AgentEntry[], IdeError>(__TAURI_INVOKE("agent_entries", { task, subagent })),
|
||||
agentCreate: (request: NewAgentTask) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_create", { request })),
|
||||
/** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */
|
||||
agentPrompt: (task: string, prompt: AgentPrompt, now: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_prompt", { task, prompt, now })),
|
||||
@@ -71,7 +72,8 @@ export const commands = {
|
||||
agentSendQueuedNow: (task: string, queued: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_send_queued_now", { task, queued })),
|
||||
/** Changes a session setting (model, mode, thinking effort…). */
|
||||
agentSetConfig: (task: string, option: string, value: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_set_config", { task, option, value })),
|
||||
agentCancel: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_cancel", { task })),
|
||||
/** Stops the task's current turn, or with `subagent` only that subagent's work. */
|
||||
agentCancel: (task: string, subagent: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_cancel", { task, subagent })),
|
||||
/** Answers a permission request; `option: null` cancels it. */
|
||||
agentRespond: (task: string, permission: number, option: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_respond", { task, permission, option })),
|
||||
agentArchive: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_archive", { task })),
|
||||
@@ -1001,7 +1003,25 @@ doneMs?: number | null } | { type: "agent"; text: string } | { type: "thought";
|
||||
/** ACP tool kind: `read`, `edit`, `execute`, `search`, `fetch`, `think`… */
|
||||
kind: string; status: AgentToolStatus; locations: IpcPath[];
|
||||
/** Text the tool reported (command output…), truncated. */
|
||||
output: string | null; diffs: AgentToolDiff[] } | { type: "plan"; items: AgentPlanItem[] } | { type: "permission"; id: number; title: string; options: AgentPermissionOption[];
|
||||
output: string | null; diffs: AgentToolDiff[] } | { type: "plan"; items: AgentPlanItem[] } |
|
||||
/** A subagent the agent started: its own session, whose entries are listed separately. */
|
||||
{ type: "subagent";
|
||||
/** The subagent's session id. */
|
||||
id: string; title: string | null; description: string | null; status: AgentSubagentStatus; error: string | null;
|
||||
/** What it was asked to do, last. */
|
||||
prompt: string | null;
|
||||
/** Its answer. */
|
||||
reply: string | null;
|
||||
/** Tool calls it made. */
|
||||
tools: number;
|
||||
/** Title of its latest tool call. */
|
||||
activity: string | null; startedMs: number | null;
|
||||
/** When it last stopped working; `None` while it works. */
|
||||
endedMs: number | null;
|
||||
/** Whether it can be stopped on its own. */
|
||||
cancellable: boolean } |
|
||||
/** A message between sessions in a subagent's conversation: its task (`incoming`) or its report. */
|
||||
{ type: "message"; incoming: boolean; text: string } | { type: "permission"; id: number; title: string; options: AgentPermissionOption[];
|
||||
/** Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". */
|
||||
chosen: string | null } | { type: "error"; message: string };
|
||||
|
||||
@@ -1009,8 +1029,8 @@ chosen: string | null } | { type: "error"; message: string };
|
||||
export type AgentEvent =
|
||||
/** A task was created or its metadata changed. */
|
||||
{ type: "task"; task: AgentTask } | { type: "removed"; id: string } |
|
||||
/** Entry `index` of `task` was added or replaced. */
|
||||
{ type: "entry"; task: string; index: number; entry: AgentEntry };
|
||||
/** Entry `index` of `task` (of its subagent `subagent`) was added or replaced. */
|
||||
{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry };
|
||||
|
||||
export type AgentImage = {
|
||||
mimeType: string,
|
||||
@@ -1065,6 +1085,11 @@ export type AgentStatus =
|
||||
/** The turn ended; the result can be reviewed or the conversation continued. */
|
||||
"ready" | "failed" | "archived";
|
||||
|
||||
/** What a subagent is doing, as its parent reports it. */
|
||||
export type AgentSubagentStatus = "running" |
|
||||
/** One of its requests (a permission) waits for an answer. */
|
||||
"needsInput" | "done" | "cancelled" | "failed";
|
||||
|
||||
export type AgentTask = {
|
||||
id: string,
|
||||
title: string,
|
||||
|
||||
@@ -550,3 +550,185 @@
|
||||
.removed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Subagents: a card per subagent in its parent's conversation, and the way into its own. */
|
||||
.subagent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.subagentHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.subagentOpen {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-row);
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.subagentOpen:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.subagentTitle {
|
||||
flex: none;
|
||||
max-width: 50%;
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.subagentDescription {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
color: var(--fg-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.subagentMeta {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.subagentActivity {
|
||||
padding-left: 22px;
|
||||
color: var(--fg-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.subagentSection > summary {
|
||||
padding-left: 22px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.subagentSection > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.subagentSection > summary:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.subagentSection > :not(summary) {
|
||||
margin: 4px 0 2px 22px;
|
||||
}
|
||||
.subagentReply {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
.done {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.subagentBar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 8px 0 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
}
|
||||
.crumbs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.crumb {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
.crumb:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.crumbCurrent {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.messageFrom {
|
||||
margin-bottom: 2px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
flex: none;
|
||||
padding: 10px 16px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.subagentStrip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.subagentChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 240px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 11px;
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--fs-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.subagentChip:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.subagentChipTitle {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/** An agent task as an editor tab: the conversation, a composer and the task's changed files. */
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type { AgentChange, AgentEntry, AgentTask, AgentUsage } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, onIdeEvent } from "@/platform/ipc";
|
||||
@@ -28,7 +29,18 @@ import { canSend, emptyDraft, usageLabel, workTime } from "./composerModel";
|
||||
import { ConfigPickers } from "./ConfigPickers";
|
||||
import { findRanges, formatDuration, formatSentAt } from "./conversation";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { branchLabel, isBusy, providerIcon, statusLabel } from "./model";
|
||||
import {
|
||||
branchLabel,
|
||||
entriesKey,
|
||||
findSubagent,
|
||||
isBusy,
|
||||
providerIcon,
|
||||
statusLabel,
|
||||
subagentStatusLabel,
|
||||
subagentTitle,
|
||||
subagentWorking,
|
||||
type SubagentEntry,
|
||||
} from "./model";
|
||||
import {
|
||||
archiveTask,
|
||||
cancelTask,
|
||||
@@ -56,11 +68,23 @@ export function SessionEditor({ input }: { input: EditorInput }) {
|
||||
const entries = useAgents((s) => s.entries.get(id));
|
||||
const changes = useAgents((s) => s.changes.get(id));
|
||||
const head = useLocalHead(task?.isolation === "local" ? task.status : null);
|
||||
// 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;
|
||||
const viewEntries = useAgents((s) => s.entries.get(entriesKey(id, view)));
|
||||
// Each subagent's card, from its parent's conversation.
|
||||
const cards = useAgents(
|
||||
useShallow((s) => path.map((sub, i) => findSubagent(s.entries.get(entriesKey(id, path[i - 1] ?? null)), sub))),
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadEntries(id).catch(() => undefined);
|
||||
void refreshChanges(id);
|
||||
return markVisible(id);
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
if (view) void loadEntries(id, view).catch(() => undefined);
|
||||
}, [id, view]);
|
||||
const openSubagent = (sub: string) => setPath((p) => [...p, sub]);
|
||||
const [finding, setFinding] = useState(false);
|
||||
const [findFocus, setFindFocus] = useState(0);
|
||||
const [session] = useState<FocusedSession>(() => ({
|
||||
@@ -88,15 +112,25 @@ export function SessionEditor({ input }: { input: EditorInput }) {
|
||||
>
|
||||
<div className={styles.conversation}>
|
||||
<Header task={task} entries={entries ?? null} head={head} />
|
||||
{view && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
|
||||
<PermissionBanner task={task} entries={entries ?? null} />
|
||||
<Stream
|
||||
key={view ?? ""}
|
||||
task={task}
|
||||
entries={entries ?? null}
|
||||
entries={viewEntries ?? null}
|
||||
subagent={view ? (cards.at(-1) ?? null) : null}
|
||||
onOpenSubagent={openSubagent}
|
||||
finding={finding}
|
||||
findFocus={findFocus}
|
||||
onCloseFind={() => setFinding(false)}
|
||||
/>
|
||||
<SessionComposer task={task} entries={entries ?? null} />
|
||||
{view ? (
|
||||
<div className={styles.readOnly}>
|
||||
<T>Subagents take their instructions from the agent that started them.</T>
|
||||
</div>
|
||||
) : (
|
||||
<SessionComposer task={task} entries={entries ?? null} onOpenSubagent={openSubagent} />
|
||||
)}
|
||||
</div>
|
||||
<ChangesPane task={task} changes={changes ?? null} head={head} />
|
||||
</div>
|
||||
@@ -210,16 +244,70 @@ function useNow(interval: number): number {
|
||||
return now;
|
||||
}
|
||||
|
||||
/** Where a subagent conversation sits: the way back to the task, and the subagent's state. */
|
||||
function SubagentBar({
|
||||
task,
|
||||
path,
|
||||
cards,
|
||||
onNavigate,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
path: string[];
|
||||
cards: Array<SubagentEntry | undefined>;
|
||||
onNavigate: (path: string[]) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const card = cards.at(-1);
|
||||
return (
|
||||
<div className={styles.subagentBar}>
|
||||
<IconButton icon="arrow-left" title="Back" size="small" onClick={() => onNavigate(path.slice(0, -1))} />
|
||||
<nav className={styles.crumbs} aria-label={tx("Subagent")}>
|
||||
<button type="button" className={styles.crumb} onClick={() => onNavigate([])} title={task.title}>
|
||||
{task.title}
|
||||
</button>
|
||||
{path.map((sub, i) => {
|
||||
const title = cards[i] ? subagentTitle(cards[i]) : tx("Subagent");
|
||||
return (
|
||||
<Fragment key={sub}>
|
||||
<Icon name="chevron-right" size={12} className={styles.muted} />
|
||||
{i === path.length - 1 ? (
|
||||
<span className={styles.crumbCurrent} title={cards[i]?.description ?? title}>
|
||||
{title}
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className={styles.crumb} onClick={() => onNavigate(path.slice(0, i + 1))}>
|
||||
{title}
|
||||
</button>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{card && (
|
||||
<span className={styles.muted}>
|
||||
<T>{subagentStatusLabel(card.status)}</T>
|
||||
</span>
|
||||
)}
|
||||
{card && subagentWorking(card.status) && card.cancellable && (
|
||||
<IconButton icon="stop" title="Stop Subagent" size="small" onClick={() => void cancelTask(task.id, card.id)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamProps {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
/** The card of the subagent whose conversation this is; `null`: the task's own. */
|
||||
subagent: SubagentEntry | null;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
finding: boolean;
|
||||
/** Bumped by Find in Conversation: focus the find field again. */
|
||||
findFocus: number;
|
||||
onCloseFind: () => void;
|
||||
}
|
||||
|
||||
function Stream({ task, entries, finding, findFocus, onCloseFind }: StreamProps) {
|
||||
function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, onCloseFind }: StreamProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
@@ -255,9 +343,16 @@ function Stream({ task, entries, finding, findFocus, onCloseFind }: StreamProps)
|
||||
{entries === null ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
entries.map((entry, i) => <Entry key={i} task={task} entry={entry} />)
|
||||
entries.map((entry, i) => <Entry key={i} task={task} entry={entry} onOpenSubagent={onOpenSubagent} />)
|
||||
)}
|
||||
{(task.status === "running" || task.status === "starting") && !waiting && (
|
||||
{subagent && subagentWorking(subagent.status) && (
|
||||
<div className={styles.working}>
|
||||
<Spinner size={12} />
|
||||
<T>{subagent.status === "needsInput" ? "Waiting for your answer…" : "Working…"}</T>
|
||||
{subagent.startedMs != null && <Elapsed since={subagent.startedMs} />}
|
||||
</div>
|
||||
)}
|
||||
{!subagent && (task.status === "running" || task.status === "starting") && !waiting && (
|
||||
<div className={styles.working}>
|
||||
<Spinner size={12} />
|
||||
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
|
||||
@@ -439,7 +534,15 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
think: "todo",
|
||||
};
|
||||
|
||||
function Entry({ task, entry }: { task: AgentTask; entry: AgentEntry }) {
|
||||
function Entry({
|
||||
task,
|
||||
entry,
|
||||
onOpenSubagent,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entry: AgentEntry;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
}) {
|
||||
switch (entry.type) {
|
||||
case "user":
|
||||
return <UserMessage task={task} entry={entry} />;
|
||||
@@ -470,6 +573,24 @@ function Entry({ task, entry }: { task: AgentTask; entry: AgentEntry }) {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "subagent":
|
||||
return <SubagentCard task={task} entry={entry} onOpen={() => onOpenSubagent(entry.id)} />;
|
||||
case "message":
|
||||
return entry.incoming ? (
|
||||
<div className={styles.message}>
|
||||
<div className={styles.messageFrom}>
|
||||
<T>Task from the parent agent</T>
|
||||
</div>
|
||||
<div className={styles.user}>{entry.text}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className={styles.messageFrom}>
|
||||
<T>Report to the parent agent</T>
|
||||
</div>
|
||||
<Markdown text={entry.text} cwd={task.cwd} />
|
||||
</div>
|
||||
);
|
||||
case "permission":
|
||||
return <PermissionLine task={task} entry={entry} />;
|
||||
case "error":
|
||||
@@ -568,6 +689,102 @@ function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentStatusIcon({ status }: { status: SubagentEntry["status"] }) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return <Spinner size={11} />;
|
||||
case "needsInput":
|
||||
return <Icon name="warning" size={13} label="Needs input" />;
|
||||
case "done":
|
||||
return <Icon name="check" size={13} className={styles.done} label="Done" />;
|
||||
case "cancelled":
|
||||
return <Icon name="minus" size={13} className={styles.muted} label="Stopped" />;
|
||||
case "failed":
|
||||
return <Icon name="close" size={13} className={styles.removed} label="Failed" />;
|
||||
}
|
||||
}
|
||||
|
||||
/** A subagent in its parent's conversation: what it does, its latest step, what it was asked and answered. */
|
||||
function SubagentCard({ task, entry, onOpen }: { task: AgentTask; entry: SubagentEntry; onOpen: () => void }) {
|
||||
const tx = useTx();
|
||||
const working = subagentWorking(entry.status);
|
||||
const now = useNow(working ? 1000 : 60_000);
|
||||
const time = entry.startedMs == null ? null : (entry.endedMs ?? now) - entry.startedMs;
|
||||
const title = subagentTitle(entry);
|
||||
return (
|
||||
<div className={styles.subagent}>
|
||||
<div className={styles.subagentHead}>
|
||||
<button type="button" className={styles.subagentOpen} onClick={onOpen} title={tx("Open the subagent's conversation")}>
|
||||
<SubagentStatusIcon status={entry.status} />
|
||||
<Icon name="toolwindow-agents" size={14} className={styles.muted} />
|
||||
<span className={styles.subagentTitle}>{title}</span>
|
||||
{entry.description && entry.description !== title && (
|
||||
<span className={styles.subagentDescription}>{entry.description}</span>
|
||||
)}
|
||||
<span className={styles.subagentMeta}>
|
||||
{[
|
||||
entry.tools > 0 && `${entry.tools} ${tx(entry.tools === 1 ? "tool call" : "tool calls")}`,
|
||||
time != null && formatDuration(Math.max(0, time)),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
<Icon name="chevron-right" size={12} className={styles.muted} />
|
||||
</button>
|
||||
{working && entry.cancellable && (
|
||||
<IconButton icon="stop" title="Stop Subagent" size="small" onClick={() => void cancelTask(task.id, entry.id)} />
|
||||
)}
|
||||
</div>
|
||||
{working && entry.activity && (
|
||||
<div className={styles.subagentActivity} title={entry.activity}>
|
||||
{shortTitle(task, entry.activity)}
|
||||
</div>
|
||||
)}
|
||||
{entry.error && (
|
||||
<div className={styles.error} role="alert">
|
||||
<Icon name="error" size={14} />
|
||||
<span>{entry.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.prompt && (
|
||||
<details className={styles.subagentSection}>
|
||||
<summary>
|
||||
<T>Task</T>
|
||||
</summary>
|
||||
<div className={styles.thoughtText}>{entry.prompt}</div>
|
||||
</details>
|
||||
)}
|
||||
{entry.reply && (
|
||||
<details className={styles.subagentSection}>
|
||||
<summary>
|
||||
<T>Result</T>
|
||||
</summary>
|
||||
<div className={styles.subagentReply}>
|
||||
<Markdown text={entry.reply} cwd={task.cwd} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The subagents of the conversation still at work, to look in on. */
|
||||
function WorkingSubagents({ entries, onOpen }: { entries: AgentEntry[] | null; onOpen: (id: string) => void }) {
|
||||
const tx = useTx();
|
||||
const working = (entries ?? []).filter((e): e is SubagentEntry => e.type === "subagent" && subagentWorking(e.status));
|
||||
if (!working.length) return null;
|
||||
return (
|
||||
<div className={styles.subagentStrip} aria-label={tx("Subagents at work")}>
|
||||
{working.map((e) => (
|
||||
<button key={e.id} type="button" className={styles.subagentChip} onClick={() => onOpen(e.id)} title={e.activity ?? undefined}>
|
||||
<SubagentStatusIcon status={e.status} />
|
||||
<span className={styles.subagentChipTitle}>{subagentTitle(e)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Agent-written titles with the task's own folder dropped from paths. */
|
||||
const shortTitle = (task: AgentTask, title: string) => title.replaceAll(`${task.cwd}/`, "");
|
||||
|
||||
@@ -606,7 +823,15 @@ function PermissionBanner({ task, entries }: { task: AgentTask; entries: AgentEn
|
||||
);
|
||||
}
|
||||
|
||||
function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
|
||||
function SessionComposer({
|
||||
task,
|
||||
entries,
|
||||
onOpenSubagent,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
}) {
|
||||
const project = useProject((s) => s.project);
|
||||
const composer = useAgents((s) => s.composers.get(task.id)) ?? emptyDraft();
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -630,6 +855,7 @@ function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEnt
|
||||
);
|
||||
return (
|
||||
<div className={styles.composerArea}>
|
||||
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
|
||||
{task.queue.length > 0 && <Queue task={task} />}
|
||||
<Composer
|
||||
id={task.id}
|
||||
|
||||
@@ -16,7 +16,17 @@ import {
|
||||
usageLabel,
|
||||
workTime,
|
||||
} from "./composerModel";
|
||||
import { age, attentionChange, branchLabel, groupTasks, upsertEntry } from "./model";
|
||||
import {
|
||||
age,
|
||||
attentionChange,
|
||||
branchLabel,
|
||||
entriesKey,
|
||||
findSubagent,
|
||||
groupTasks,
|
||||
subagentTitle,
|
||||
upsertEntry,
|
||||
type SubagentEntry,
|
||||
} from "./model";
|
||||
import { markdownHtml } from "./Markdown";
|
||||
|
||||
function task(id: string, status: AgentStatus, updatedMs: number): AgentTask {
|
||||
@@ -185,6 +195,30 @@ describe("agents model", () => {
|
||||
expect(attentionChange(undefined, "ready")).toBeNull();
|
||||
});
|
||||
|
||||
it("finds subagent cards and keys their conversations apart", () => {
|
||||
const card: SubagentEntry = {
|
||||
type: "subagent",
|
||||
id: "c1",
|
||||
title: null,
|
||||
description: "Find the lexer",
|
||||
status: "running",
|
||||
error: null,
|
||||
prompt: null,
|
||||
reply: null,
|
||||
tools: 0,
|
||||
activity: null,
|
||||
startedMs: 0,
|
||||
endedMs: null,
|
||||
cancellable: false,
|
||||
};
|
||||
expect(findSubagent([{ type: "agent", text: "a" }, card], "c1")).toBe(card);
|
||||
expect(findSubagent(undefined, "c1")).toBeUndefined();
|
||||
expect(subagentTitle(card)).toBe("Find the lexer");
|
||||
expect(subagentTitle({ ...card, title: "Explore" })).toBe("Explore");
|
||||
expect(entriesKey("t", null)).toBe("t");
|
||||
expect(entriesKey("t", "c1")).not.toBe(entriesKey("t", null));
|
||||
});
|
||||
|
||||
it("formats labels", () => {
|
||||
expect(branchLabel("agent/fix-lexer")).toBe("fix-lexer");
|
||||
expect(branchLabel(null)).toBe("");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Pure helpers: task grouping, entry updates, labels. */
|
||||
import type { AgentEntry, AgentStatus, AgentTask } from "@/bindings";
|
||||
import type { AgentEntry, AgentStatus, AgentSubagentStatus, AgentTask } from "@/bindings";
|
||||
|
||||
export const SESSION_KIND = "agent-session";
|
||||
export const HOME_KIND = "agent-home";
|
||||
@@ -8,6 +8,35 @@ export const TOOL_WINDOW = "agents";
|
||||
|
||||
export const sessionKey = (task: string) => `agent:${task}`;
|
||||
|
||||
/** Key of a conversation in the entries store: the task's own, or its subagent `subagent`'s. */
|
||||
export const entriesKey = (task: string, subagent: string | null) => (subagent ? `${task}/${subagent}` : task);
|
||||
|
||||
export type SubagentEntry = Extract<AgentEntry, { type: "subagent" }>;
|
||||
|
||||
/** The card of subagent `id` in its parent's `entries`. */
|
||||
export const findSubagent = (entries: AgentEntry[] | undefined, id: string) =>
|
||||
entries?.find((e): e is SubagentEntry => e.type === "subagent" && e.id === id);
|
||||
|
||||
export const subagentWorking = (s: AgentSubagentStatus) => s === "running" || s === "needsInput";
|
||||
|
||||
/** "Explore", else what it was started for. */
|
||||
export const subagentTitle = (e: SubagentEntry) => e.title ?? e.description ?? "Subagent";
|
||||
|
||||
export function subagentStatusLabel(s: AgentSubagentStatus): string {
|
||||
switch (s) {
|
||||
case "running":
|
||||
return "Running";
|
||||
case "needsInput":
|
||||
return "Needs input";
|
||||
case "done":
|
||||
return "Done";
|
||||
case "cancelled":
|
||||
return "Stopped";
|
||||
case "failed":
|
||||
return "Failed";
|
||||
}
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS = new Set(["claude", "codex", "gemini", "copilot", "cursor", "opencode", "qwen", "kimi", "goose", "auggie", "kiro", "vibe"]);
|
||||
|
||||
/** The agent's logo; custom agents get the generic agents icon. */
|
||||
|
||||
@@ -15,7 +15,7 @@ import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
|
||||
import { withQuote } from "./conversation";
|
||||
import { HOME_KEY, HOME_KIND, SESSION_KIND, attentionChange, isBusy, sessionKey, upsertEntry } from "./model";
|
||||
import { HOME_KEY, HOME_KIND, SESSION_KIND, attentionChange, entriesKey, isBusy, sessionKey, upsertEntry } from "./model";
|
||||
|
||||
/** New Task page settings (its text lives in the `HOME` composer). */
|
||||
export interface Draft {
|
||||
@@ -35,7 +35,7 @@ interface AgentsState {
|
||||
/** Why the tasks could not be loaded. */
|
||||
error: string | null;
|
||||
tasks: Map<string, AgentTask>;
|
||||
/** Entries of the tasks opened in this window. */
|
||||
/** Entries of the tasks (and subagents) opened in this window, by `entriesKey`. */
|
||||
entries: Map<string, AgentEntry[]>;
|
||||
changes: Map<string, AgentChange[]>;
|
||||
providers: AgentProvider[];
|
||||
@@ -170,11 +170,12 @@ function onEvent(e: AgentEvent) {
|
||||
break;
|
||||
}
|
||||
case "entry": {
|
||||
const current = s.entries.get(e.task);
|
||||
const key = entriesKey(e.task, e.subagent);
|
||||
const current = s.entries.get(key);
|
||||
if (!current) return;
|
||||
const next = upsertEntry(current, e.index, e.entry);
|
||||
if (next) useAgents.setState({ entries: new Map(s.entries).set(e.task, next) });
|
||||
else void loadEntries(e.task);
|
||||
if (next) useAgents.setState({ entries: new Map(s.entries).set(key, next) });
|
||||
else void loadEntries(e.task, e.subagent);
|
||||
if (e.entry.type === "tool" && e.entry.status === "completed" && e.entry.diffs.length) {
|
||||
scheduleChanges(e.task);
|
||||
}
|
||||
@@ -200,9 +201,10 @@ function notifyChange(prev: AgentTask | undefined, task: AgentTask) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadEntries(task: string): Promise<void> {
|
||||
const entries = await call(commands.agentEntries(task));
|
||||
useAgents.setState((s) => ({ entries: new Map(s.entries).set(task, entries) }));
|
||||
/** Loads the entries of the task's conversation, or of its subagent `subagent`'s. */
|
||||
export async function loadEntries(task: string, subagent: string | null = null): Promise<void> {
|
||||
const entries = await call(commands.agentEntries(task, subagent));
|
||||
useAgents.setState((s) => ({ entries: new Map(s.entries).set(entriesKey(task, subagent), entries) }));
|
||||
}
|
||||
|
||||
export async function refreshChanges(task: string): Promise<void> {
|
||||
@@ -373,7 +375,8 @@ export function addReferences(refs: Array<{ path: string; rel: string; mention:
|
||||
else openHome();
|
||||
}
|
||||
|
||||
export const cancelTask = (task: string) => call(commands.agentCancel(task));
|
||||
/** Stops the task's turn, or with `subagent` only that subagent's work. */
|
||||
export const cancelTask = (task: string, subagent: string | null = null) => call(commands.agentCancel(task, subagent));
|
||||
export const respond = (task: string, permission: number, option: string | null) =>
|
||||
call(commands.agentRespond(task, permission, option));
|
||||
export const archiveTask = (task: string) => call(commands.agentArchive(task));
|
||||
|
||||
@@ -3208,6 +3208,17 @@
|
||||
"New worktree": "新 worktree",
|
||||
"Starting…": "正在启动…",
|
||||
"Waiting for your answer": "等待你的回答",
|
||||
"Waiting for your answer…": "等待你的回答…",
|
||||
"Subagent": "子 agent",
|
||||
"Subagents at work": "工作中的子 agent",
|
||||
"Stop Subagent": "停止子 agent",
|
||||
"Open the subagent's conversation": "打开子 agent 的对话",
|
||||
"Subagents take their instructions from the agent that started them.": "子 agent 只接收启动它的 agent 的指令。",
|
||||
"Task from the parent agent": "父 agent 交给的任务",
|
||||
"Report to the parent agent": "交给父 agent 的报告",
|
||||
"Task": "任务",
|
||||
"tool call": "次工具调用",
|
||||
"tool calls": "次工具调用",
|
||||
"The agent stopped": "Agent 已停止",
|
||||
"Not installed": "未安装",
|
||||
"{command} was not found on PATH": "在 PATH 中找不到 {command}",
|
||||
|
||||
Reference in new issue
Заблокувати користувача