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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-28 17:51:55 +08:00
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,
+182
Переглянути файл
@@ -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;
}
+235 -9
Переглянути файл
@@ -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}
+35 -1
Переглянути файл
@@ -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("");
+30 -1
Переглянути файл
@@ -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. */
+12 -9
Переглянути файл
@@ -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));
+11
Переглянути файл
@@ -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}",