feat(web): subagent side panel with a live call-graph, compact chips in the stream (#78)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-07-28 00:17:18 +08:00
1 parent d3c8f1da7b
commit 08f59baa70
22 files changed
+2330 -175

No files matched your search

+43 -1
View File
@@ -3,8 +3,13 @@
* Branches on request body:
* - title request (prompt contains "concise title") -> short text
* - files-card probe ("files card test") -> text with two backtick paths (one real, one missing)
* - subagent's own turn (its prompt is the only user text) -> final text
* - subagent's own turns (its prompt is the only user text) -> tool_use(exec_command) first,
* then the report text once the tool_result is back — the tool call gives the child a real
* approval point (under always-ask it parks on a NESTED approval, which the subagents-panel
* e2e approves from the panel; under allow-all it auto-runs)
* - parent asked to delegate ("run a subagent") -> tool_use(run_subagent)
* - a repeat delegation later in the same conversation ("run another subagent", keyed on the
* LAST message so history can't shadow it) -> tool_use(run_subagent) again
* - "slow stream test" -> tool_use(exec_command) with a command that prints one line
* every 200ms for ~8s (reload-midstream.spec reloads while its output streams)
* - "slow text test" -> a long text streamed one delta every 200ms for ~8s
@@ -246,7 +251,44 @@ const server = http.createServer((req, res) => {
return;
}
// A REPEAT delegation later in the same conversation (the task-scoped panel e2e needs a
// second spawning Task): keyed on the LAST message only — the whole-history flags above
// ("run a subagent" + hasToolResult) can never take this branch once a first delegation
// sits in the history. The child flow it spawns is identical (same SUBAGENT_PROMPT). The
// ~800ms delay keeps the e2e's two observations in order: the task boundary first CLOSES
// the panel, and only then does this spawn auto-open it again.
const lastFlat = JSON.stringify(messages[messages.length - 1] ?? {});
if (lastFlat.includes("run another subagent")) {
setTimeout(() => {
block(
res,
0,
{ type: "tool_use", id: "toolu_mock_sub2", name: "run_subagent", input: {} },
[
{ type: "input_json_delta", partial_json: '{"prompt": ' },
{ type: "input_json_delta", partial_json: `${JSON.stringify(SUBAGENT_PROMPT)}}` },
],
);
messageStop(res, "tool_use", 15);
}, 800);
return;
}
if (isSubagentTurn) {
// Child turn 1: one tool call before reporting (see the header comment — the child needs
// its own approval point for the nested-approval e2e). The command sleeps ~1s so the
// whole parent Task reliably OUTLIVES the draft-flow navigation — the auto-open e2e
// needs the client to attach while the spawn is still live (a real model is far slower;
// without the sleep the mock finishes the entire tree before the page even connects).
// Turn 2: the report text, the exact string the title branch keys on.
if (!hasToolResult) {
block(res, 0, { type: "tool_use", id: "toolu_sub_exec", name: "exec_command", input: {} }, [
{ type: "input_json_delta", partial_json: '{"cmd"' },
{ type: "input_json_delta", partial_json: ': "sleep 1; echo counting"}' },
]);
messageStop(res, "tool_use", 10);
return;
}
block(res, 0, { type: "text", text: "" }, [
{ type: "text_delta", text: "Subagent report: 3 TODOs" },
]);
+291 -58
View File
@@ -1,31 +1,36 @@
/**
* The subagent (run_subagent) card survives a **reload**: the parent Trace only stores a single
* session_meta pointer for the child session; the server's readMessages expands the child Trace
* from that pointer and fills in the origin chain, and the frontend reattaches the child session
* to the tool card from that. Also verifies the child session's title is generated by the model
* from **the child session's own conversation** (the mock only replies with "Subagent TODO
* summary" when the title request sees the child's own answer), and that discovery-based
* backfill must not collapse it to NULL.
* Subagents side panel: a spawned child session leaves only a full-width shortcut row in the
* main stream; clicking it opens the right-hand panel with that Task's call graph (root +
* children, clickable nodes, per-node elapsed time) above the selected child's live
* conversation. Verifies the row/panel flow live, across a mid-run reload and an
* after-completion reload (the parent Trace stores only a session_meta pointer for the child;
* the server expands the child Trace and the frontend reattaches it), the agents/files panel
* exclusivity (opening either closes the other, from any path), the TASK-SCOPED visibility (a
* new Task closes the panel at its boundary; only a manual open or the current task's own
* spawn brings it back, the auto-open re-armed per task and a mid-task manual close
* respected), the historical topology (the old turn's row pins its Task back; a toolbar reopen
* returns to the latest), the child-session title generated from the child's own conversation,
* the sidebar "Subagents" folder, and — in a second always-ask session — that an approval
* INSIDE the child stays discoverable (row badge) and actionable from the panel.
* A dedicated reload-free test drives the full task-scoped lifecycle (auto-open, boundary
* close from an open panel, per-task re-arm, manual close held for the rest of the task), and
* a draft-flow test covers /chat/new: the panel auto-opens on the session's first live spawn,
* and the child conversation shows its own user prompt both live (forwarded by run_subagent)
* and after a reload (child-Trace expansion).
*
* Standalone spec: shares one server with chat.spec.mjs, so it registers its own user here
* (not the first user; registration auto-provisions a `project-<8hex>`), independent of
* chat.spec's execution order.
* Standalone spec: shares one server with chat.spec.mjs, so it registers its own users here
* (registration auto-provisions a `project-<8hex>`), independent of chat.spec's execution order.
*/
import { test, expect } from "@playwright/test";
import { provisionAndLogin } from "./auth.mjs";
const BASE = process.env.BASE_URL;
const MOCK = process.env.MOCK_URL;
const U = "subuser";
const P = "password123";
test("subagent card survives a reload; child session title is generated from its own conversation", async ({
page,
}) => {
await provisionAndLogin(page.request, U, P);
// Account creation auto-provisions a default Project; must use it (the frontend selects it
// too) — don't create another one.
/** Register a user, wire the mock model into their auto-provisioned Project, and create one session. */
async function provisionSession(page, username, sessionOverrides = {}) {
await provisionAndLogin(page.request, username, P);
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
expect(projects.projects, "auto-provisioned project").toHaveLength(1);
const projectId = projects.projects[0].projectId;
@@ -47,15 +52,60 @@ test("subagent card survives a reload; child session title is generated from its
expect(put.ok(), "put models").toBeTruthy();
const agentId = "default_agent";
// Approval defaults to allow-all: child sessions inherit the parent's approval mode, no
// manual approval needed.
const sessRes = await page.request.post(
`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`,
{ data: { provider: "custom", modelId: "claude-4-8" } },
{ data: { provider: "custom", modelId: "claude-4-8", ...sessionOverrides } },
);
expect(sessRes.ok(), `create session: ${await sessRes.text()}`).toBeTruthy();
const sess = await sessRes.json();
const sessionId = sess.session.sessionId;
return { projectId, agentId, sessionId: sess.session.sessionId };
}
/** The subagent shortcut row in the message stream (accessible name leads with 子会话 + the resolved agent name — unchanged by the bar restyle). */
const chipOf = (page) => page.getByRole("button", { name: /子会话/ }).first();
/** The child session's own user prompt (run_subagent's `prompt`): must show in the panel's child conversation, live and after reloads. */
const CHILD_PROMPT = "Count the TODO items in the repository";
/**
* Wait for the chip, expanding its "Reasoning & Tools" group when needed: the group is open
* while the turn runs but collapses (chip included) once the turn is over, and around a reload
* either state is possible — poll the whole reveal so every interleaving converges.
*/
async function revealChip(page) {
const chip = chipOf(page);
await expect(async () => {
if (await chip.isVisible()) return;
const done = page.getByRole("button", { name: /运行完毕/ }).first();
if (await done.isVisible()) await done.click();
expect(await chip.isVisible()).toBeTruthy();
}).toPass({ timeout: 15_000 });
}
/**
* Click the chip and wait for the docked panel (its title is a heading; the toolbar toggle
* with the same text is a button). The reveal + click runs as one polled block: the turn can
* finish between the two steps and collapse the group over the chip, so a failed click retries
* from the reveal.
*/
async function openPanelViaChip(page) {
const chip = chipOf(page);
await expect(async () => {
if (!(await chip.isVisible())) {
const done = page.getByRole("button", { name: /运行完毕/ }).first();
if (await done.isVisible()) await done.click();
}
await chip.click({ timeout: 2000 });
}).toPass({ timeout: 15_000 });
await expect(page.getByRole("heading", { name: "智能体面板" })).toBeVisible();
}
test("subagent renders as a chip; the panel shows the call graph and child conversation, and survives reloads", async ({
page,
}) => {
// Approval defaults to allow-all: child sessions inherit the parent's approval mode, no
// manual approval needed in this test.
const { projectId, agentId, sessionId } = await provisionSession(page, "subuser");
await page.goto(`${BASE}/chat/${sessionId}`);
const ta = page.getByPlaceholder(/输入消息/);
@@ -63,48 +113,99 @@ test("subagent card survives a reload; child session title is generated from its
await ta.fill("run a subagent");
await page.getByRole("button", { name: "发送" }).click();
// Parent's final answer (turn 2): the whole turn has ended. Nothing changes with streaming
// after this, so assertions are deterministic (turn end collapses the "Reasoning & tools"
// group, and child session end collapses the child session card).
// The child session leaves only a shortcut row in the stream (the nested conversation no
// longer renders inline); it appears as soon as the child's first message binds.
await revealChip(page);
// --- Mid-run reload: the chip must come back from the rebuilt history and reopen a working panel. ---
await page.reload();
await revealChip(page);
await openPanelViaChip(page);
// The child conversation streams inside the panel (this text renders nowhere else while the
// run_subagent tool card stays collapsed).
await expect(page.getByText("Subagent report: 3 TODOs")).toBeVisible();
// Parent's final answer: the whole turn has ended; assertions below are deterministic.
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
/** Expand the "Reasoning & tools" group → expand the child session card → child session body visible. */
const openSubagent = async () => {
await page
.getByRole("button", { name: /运行完毕/ })
.first()
.click();
await expect(page.getByText("run_subagent").first()).toBeVisible();
const card = page.getByRole("button", { name: /子会话/ }).first();
await expect(card).toBeVisible();
await card.click();
await expect(page.getByText("Subagent report: 3 TODOs").first()).toBeVisible();
};
// --- Call graph: root + child (both run on default_agent, display name "General Agent"). ---
const graph = page.getByRole("group", { name: "调用关系" });
await expect(graph.getByRole("button", { name: /General Agent/ })).toHaveCount(2);
// Under live streaming, the child session card is nested inside the run_subagent tool card.
await openSubagent();
// Clicking the root switches the lower half to the main-session note; clicking the child
// brings its conversation back.
await graph.getByRole("button").first().click();
await expect(page.getByText("主会话请在对话区查看")).toBeVisible();
await graph.getByRole("button").nth(1).click();
await expect(page.getByText("Subagent report: 3 TODOs")).toBeVisible();
// --- DOM order: the subagent expansion renders BELOW the tool's own content ---
// Expand the run_subagent tool card itself so its arguments block is in the DOM, then assert
// the child-session card comes after it in document order (it used to render between the tool
// header and the expanded arguments/output).
const toolRow = page.locator("button[aria-expanded]").filter({ hasText: "run_subagent" }).first();
await toolRow.click();
await expect(toolRow).toHaveAttribute("aria-expanded", "true");
const argsPre = page.locator("pre", { hasText: '"prompt"' }).first();
await expect(argsPre).toBeVisible();
const subagentBtn = page.getByRole("button", { name: /子会话/ }).first();
const cardFollowsArgs = await argsPre.evaluate(
(pre, card) => Boolean(pre.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING),
await subagentBtn.elementHandle(),
);
expect(cardFollowsArgs, "subagent expansion renders below the tool arguments/output").toBe(true);
// --- Must still hold after reload (the fix's goal: parent Trace's child session pointer + server-side expansion) ---
// --- After-completion reload: chip reopens the panel, graph and conversation intact
// (the finished turn's group is collapsed now — revealChip expands it first). ---
await page.reload();
await openSubagent();
await revealChip(page);
await openPanelViaChip(page);
await expect(page.getByText("Subagent report: 3 TODOs")).toBeVisible();
// The child conversation keeps its USER side across a reload (child-Trace expansion carries
// the child's own user messages; live streaming forwards the same message — both paths must
// render the user bubble).
await expect(page.getByText(CHILD_PROMPT)).toBeVisible();
await expect(
page.getByRole("group", { name: "调用关系" }).getByRole("button", { name: /General Agent/ }),
).toHaveCount(2);
// The child node shows its settled elapsed time (wall clock of the whole spawn, derived from
// message timestamps — which is why it survives this history reload); the root shows none, so
// exactly one duration renders in the graph.
await expect(
graph.getByText(/^(\d+(\.\d+)?(ms|s)|\d+m\d+s)$/),
"one done-node duration in the graph",
).toHaveCount(1);
// --- Child session title: generated by the model from the child session's own conversation (async, poll until persisted) ---
// --- Panel exclusivity: the agents panel and the Workspace files panel never show together —
// opening either one (from any path; the toolbar toggles here) closes the other. A closed
// docked panel keeps its content MOUNTED at fixed width inside a zero-width clipping window
// (see files-panel.tsx), sliding past the viewport's right edge — so "not shown" is asserted
// as out-of-viewport, not as hidden.
const agentsToggle = page.getByRole("button", { name: "智能体面板" });
const filesToggle = page.getByRole("button", { name: "打开工作区" });
const agentsHeading = page.getByRole("heading", { name: "智能体面板" });
const filesHeading = page.getByRole("heading", { name: "文件" });
await expect(agentsToggle).toHaveAttribute("aria-expanded", "true"); // open from the chip click above
await filesToggle.click(); // open files -> agents must close
await expect(filesToggle).toHaveAttribute("aria-expanded", "true");
await expect(agentsToggle).toHaveAttribute("aria-expanded", "false");
await expect(filesHeading).toBeInViewport();
await expect(agentsHeading).not.toBeInViewport();
await agentsToggle.click(); // and the reverse: open agents -> files must close
await expect(agentsToggle).toHaveAttribute("aria-expanded", "true");
await expect(filesToggle).toHaveAttribute("aria-expanded", "false");
await expect(agentsHeading).toBeInViewport();
await expect(filesHeading).not.toBeInViewport();
// --- Historical topology: a plain follow-up Task makes the first turn's graph historical
// (the boundary itself — the panel closing on a new Task — is covered by the reload-free
// lifecycle test below; a send on a reloaded page can trip a pre-existing stream flake, so
// this block only asserts the topology behaviors). The old turn's subagent row pins that
// Task's graph back (a chip click is a manual open); a toolbar reopen returns to the DEFAULT
// latest scope (task 2 spawned nothing). ---
// Pin the page to the parent session first: if a session-list hiccup ever detours the route
// (auto-select), this fails with an explicit URL mismatch instead of a swallowed send.
await expect(page).toHaveURL(new RegExp(sessionId));
await ta.fill("hello again");
await page.getByRole("button", { name: "发送" }).click();
await expect(page.getByText("Command finished; the result looks as expected.")).toHaveCount(2);
await openPanelViaChip(page); // the first turn's row (revealed from its collapsed group)
await expect(graph.getByRole("button", { name: /General Agent/ })).toHaveCount(2);
// Node highlight follows the chip's child in the historical graph.
await expect(graph.getByRole("button", { name: /General Agent/ }).nth(1)).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.getByText("Subagent report: 3 TODOs")).toBeVisible();
await agentsToggle.click(); // close
await agentsToggle.click(); // reopen from the toolbar -> back to the DEFAULT latest-Task scope
await expect(page.getByText("本次任务尚未派生子智能体")).toBeVisible();
// --- Child session title: generated by the model from the child session's own conversation (async, poll until persisted). ---
const childOf = async () => {
const list = await (
await page.request.get(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`)
@@ -141,3 +242,135 @@ test("subagent card survives a reload; child session title is generated from its
await subagentFolder.click();
await expect(sidebar.getByText("Subagent TODO summary")).toBeVisible();
});
test("task-scoped panel lifecycle: boundary close, per-task auto-open re-arm, manual close respected", async ({
page,
}) => {
// A dedicated fresh session with NO reloads: every send happens on a live, never-reloaded
// page, so the assertions are deterministic (a send on a reloaded page can trip a
// pre-existing stream flake unrelated to the panel — documented on PR #78).
const { sessionId } = await provisionSession(page, "subuser4");
await page.goto(`${BASE}/chat/${sessionId}`);
const ta = page.getByPlaceholder(/输入消息/);
await ta.waitFor();
const agentsToggle = page.getByRole("button", { name: "智能体面板" });
// Task 1 spawns: the panel opens ITSELF once the spawn goes live (no clicks).
await ta.fill("run a subagent");
await page.getByRole("button", { name: "发送" }).click();
await expect(agentsToggle).toHaveAttribute("aria-expanded", "true");
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
await expect(agentsToggle).toHaveAttribute("aria-expanded", "true"); // stays open after the task
// Task 2 (plain): the boundary closes the panel by default — an unrelated task must not
// inherit it — and with no spawn it STAYS closed.
await ta.fill("hello again");
await page.getByRole("button", { name: "发送" }).click();
await expect(agentsToggle).toHaveAttribute("aria-expanded", "false"); // closed at the boundary
await expect(page.getByText("Command finished; the result looks as expected.")).toHaveCount(2);
await expect(agentsToggle).toHaveAttribute("aria-expanded", "false"); // no spawn: stays closed
// Task 3 spawns again: the auto-open is RE-ARMED per task. Open the panel manually first so
// the boundary demonstrably closes an OPEN panel before the spawn reopens it (the mock
// delays this spawn ~800ms, keeping boundary-close -> auto-open observable in order); a
// manual close mid-task is then respected until the next boundary.
await agentsToggle.click();
await expect(agentsToggle).toHaveAttribute("aria-expanded", "true");
await ta.fill("run another subagent");
await page.getByRole("button", { name: "发送" }).click();
await expect(agentsToggle).toHaveAttribute("aria-expanded", "false"); // boundary close first
await expect(agentsToggle).toHaveAttribute("aria-expanded", "true"); // spawn -> auto-open again
await agentsToggle.click(); // manual close mid-task: the task's one attempt is consumed
await expect(agentsToggle).toHaveAttribute("aria-expanded", "false");
await expect(page.getByText("Command finished; the result looks as expected.")).toHaveCount(3);
await expect(agentsToggle).toHaveAttribute("aria-expanded", "false"); // stayed closed for the task
});
test("an approval inside the subagent stays discoverable via the chip badge and actionable from the panel", async ({
page,
}) => {
// always-ask: the parent's run_subagent needs a manual allow, and the child's own
// exec_command then parks on a NESTED approval (the child inherits the approval mode).
const { sessionId } = await provisionSession(page, "subuser2", { approvalMode: "always-ask" });
await page.goto(`${BASE}/chat/${sessionId}`);
const ta = page.getByPlaceholder(/输入消息/);
await ta.waitFor();
await ta.fill("run a subagent");
await page.getByRole("button", { name: "发送" }).click();
// Approve the parent's run_subagent in the main stream.
await page.getByRole("button", { name: "允许" }).click();
// The child's exec_command approval surfaces on the chip (待审批 joins its accessible name)
// and as an amber dot on the toolbar toggle — discoverable with the panel closed.
const pendingChip = page.getByRole("button", { name: /子会话.*待审批/ });
await expect(pendingChip).toBeVisible();
const toolbarToggle = page.getByRole("button", { name: "智能体面板" });
await expect(toolbarToggle.locator("span.bg-amber-500")).toBeVisible();
// Open the panel from the chip and approve the child's tool call from inside it.
await pendingChip.click();
await expect(page.getByRole("heading", { name: "智能体面板" })).toBeVisible();
await expect(page.getByText("exec_command").first()).toBeVisible();
await page.getByRole("button", { name: "允许" }).click();
// The child completes inside the panel, and the parent's turn then runs to completion.
await expect(page.getByText("Subagent report: 3 TODOs")).toBeVisible();
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
// The pending badge is gone once the approval is decided (asserted on the always-visible
// toolbar toggle — the chip itself collapses with its group when the turn ends).
await expect(toolbarToggle.locator("span.bg-amber-500")).toHaveCount(0);
});
test("draft flow: the panel auto-opens on the first live spawn and the child conversation shows its user prompt", async ({
page,
}) => {
// No pre-created session: the conversation is BORN FROM THE /chat/new DRAFT — the flow where
// the panel has never been opened for the session and must introduce itself on the first
// live spawn (owner report: a child ran invisibly after new-chat + send).
await provisionAndLogin(page.request, "subuser3", P);
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
const projectId = projects.projects[0].projectId;
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
data: {
defaultModel: { provider: "custom", modelId: "claude-4-8" },
models: [
{
provider: "custom",
modelId: "claude-4-8",
apiKey: "sk-mock",
baseUrl: MOCK,
contextWindow: 200000,
},
],
},
});
expect(put.ok(), "put models").toBeTruthy();
await page.goto(`${BASE}/chat/new`);
const ta = page.getByPlaceholder(/输入消息/);
await ta.waitFor();
await ta.fill("run a subagent");
await page.getByRole("button", { name: "发送" }).click();
await page.waitForURL(/\/chat\/session-/);
// The panel auto-opens as soon as the spawn goes live — no clicks (the child's exec_command
// sleeps ~1s, so the Task reliably outlives the draft navigation and the client attaches
// while the spawn is still running).
const agentsToggle = page.getByRole("button", { name: "智能体面板" });
await expect(agentsToggle).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("heading", { name: "智能体面板" })).toBeVisible();
// The child conversation INCLUDES its user side while LIVE: run_subagent forwards the child's
// input message itself (origin-tagged), not just the model's output.
await expect(page.getByText(CHILD_PROMPT)).toBeVisible();
await expect(page.getByText("Subagent report: 3 TODOs")).toBeVisible();
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
// After a reload the same user message comes back from the child-Trace expansion.
await page.reload();
await revealChip(page);
await openPanelViaChip(page);
await expect(page.getByText(CHILD_PROMPT)).toBeVisible();
await expect(page.getByText("Subagent report: 3 TODOs")).toBeVisible();
});
@@ -0,0 +1,97 @@
/**
* Call-graph rendering for the subagents panel: hand-rolled SVG for the edges plus absolutely
* positioned real <button> nodes on top (real buttons keep keyboard focus, aria naming, and CSS
* text truncation for free — an all-SVG node would have to reinvent each). Geometry comes from
* the pure layoutTopology (agent-topology.ts); the canvas renders at its natural pixel size and
* the wrapper scrolls horizontally when the tree is deeper than the panel is wide.
* Each child node shows its wall-clock elapsed time (ticking while it runs, frozen at its last
* activity when done — never a sum of its items); the root carries no stamps and shows none.
*/
import { S } from "../../lib/strings";
import { humanizeDuration } from "../../lib/format";
import { AgentAvatar } from "../../components/ui/agent-avatar";
import { StatusIcon } from "../../components/ui/status-icon";
import { LiveDuration } from "./live-duration";
import { layoutTopology, NODE_H, NODE_W } from "./agent-topology";
import type { TopologyNode } from "./agent-topology";
export function AgentTopologyView({
nodes,
selectedId,
labelFor,
onSelect,
}: {
nodes: TopologyNode[];
/** Session id of the highlighted node (null — e.g. a focused child from an older Task — highlights nothing). */
selectedId: string | null;
/** Resolved display label for a node (the view owns name resolution; the graph only renders it). */
labelFor: (node: TopologyNode) => string;
onSelect: (node: TopologyNode) => void;
}) {
const layout = layoutTopology(nodes);
return (
<div role="group" aria-label={S.subagentPanel.topologyLabel} className="overflow-x-auto">
<div className="relative" style={{ width: layout.width, height: layout.height }}>
{/* Edges only — nodes are HTML buttons layered above. */}
<svg
width={layout.width}
height={layout.height}
className="absolute inset-0 text-gray-300 dark:text-gray-700"
aria-hidden
>
{layout.edges.map((edge) => (
<path
key={`${edge.fromId}>${edge.toId}`}
d={edge.path}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
))}
</svg>
{layout.nodes.map(({ node, x, y }) => {
const label = labelFor(node);
const selected = node.sessionId === selectedId;
const stateLabel = node.running ? S.subagentPanel.nodeRunning : S.subagentPanel.nodeDone;
return (
<button
key={node.sessionId}
type="button"
aria-label={`${label} · ${stateLabel}`}
aria-pressed={selected}
title={`${label} · ${stateLabel}`}
onClick={() => onSelect(node)}
style={{ left: x, top: y, width: NODE_W, height: NODE_H }}
className={`absolute flex items-center gap-1.5 rounded-md border bg-white px-2 text-left transition-colors duration-150 dark:bg-gray-900 ${
selected
? "border-brand-500 ring-1 ring-brand-500"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800/60"
}`}
>
<AgentAvatar id={node.agentId ?? node.sessionId} name={label} size={18} />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700 dark:text-gray-300">
{label}
</span>
{/* Elapsed: ticking from first appearance while running, frozen at the settled
span when done; omitted when the stamps are unknown (always for the root).
Decorative next to the label — the aria-label pins the accessible name. */}
{node.running
? node.startedMs !== undefined && (
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
<LiveDuration sinceMs={node.startedMs} />
</span>
)
: node.elapsedMs !== undefined && (
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
{humanizeDuration(node.elapsedMs)}
</span>
)}
{/* Status is already part of the button's accessible name: keep the glyph decorative. */}
<StatusIcon state={node.running ? "running" : "done"} size={10} />
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,370 @@
/**
* Subagent topology of one Task: pure extraction + layout, no React/DOM (unit-tested in
* test/agent-topology.test.ts; rendering lives in agent-topology-view.tsx — same split as the
* usage charts' chart-geom.ts / chart-svg.tsx).
*
* Extraction walks a single Task's slice of the main model — by default the LATEST Task (from
* the last user_text / user_image item — the same "starts a new Task" predicate the reducer
* uses; user_steering never starts one), or, for a chip clicked on an older turn, the
* HISTORICAL Task slice containing that chip's child (extractTopologyForChild). It collects
* spawned children from bound run_subagent tool cards and standalone SubagentItems, then
* recurses into each child model's own items for deeper spawns. Nodes are deduped by session id
* (an input_subagent repoll can reference the same child twice); a new Task therefore starts
* from a graph of just the root again. Each child node also carries its elapsed-time sources
* (from the nested model's activity stamps — wall clock of the whole spawn, never a sum of its
* items).
*
* Layout is a simple layered tree: depth = column (left to right), leaves get successive rows
* and a parent centers on its children; edges are orthogonal elbows between node midlines.
* Node boxes are fixed-size (labels truncate inside them), which keeps the layout free of any
* text measurement; graphs are small (≲15 nodes) and the view scrolls horizontally on overflow.
*/
import type { ChatItem, StreamModel } from "../../lib/omni/stream-model";
export interface TopologyNode {
sessionId: string;
/** Agent running this node: the child's session_meta capture first, else the run_subagent `agent_id` argument; null when unknown (root: filled by the view from the Session DTO). */
agentId: string | null;
/** Still running — the spawning card's output hasn't completed (root: the Task's own running state; a standalone item has no card, so it reads as done). */
running: boolean;
/** 0 = the main session; +1 per spawn hop. */
depth: number;
/** Origin chain used to render this node's conversation as ctx.origin (ends with this node's own session id; empty for the root). */
origin: string[];
/** Parent node's session id; null for the root. */
parentId: string | null;
/**
* Tick baseline while the node runs: when the child first appeared, preferring its first
* message timestamp (survives a reload — a mid-run refresh keeps counting from the real spawn
* moment) over the live wall-clock stamp (only meaningful while this client watched live).
* Undefined for the root (it carries no stamps) and when nothing is known — the view then
* omits the duration.
*/
startedMs?: number;
/**
* Settled span for a finished node: first→last activity of the child's subtree, derived from
* message timestamps so a history reload reproduces the same value (the local stamp pair is
* only a fallback for unparseable timestamps — after a replay it would collapse to ~0).
* Undefined for the root/unknown — omitted by the view.
*/
elapsedMs?: number;
}
/** Index of the latest Task's first item (the last user_text/user_image — the reducer's "starts a new Task" predicate); 0 when there is none (mid-stream join). */
export function latestTaskStart(items: readonly ChatItem[]): number {
for (let i = items.length - 1; i >= 0; i--) {
const kind = items[i]!.kind;
if (kind === "user_text" || kind === "user_image") return i;
}
return 0;
}
/**
* Count of Task-starting user items (the reducer's startTask predicate: user_text/user_image —
* steering chips and internal injections such as compaction summaries never become such items,
* so they never count). The subagents panel's task-boundary signal: an INCREASE means the user
* started a new Task in this session (see advancePanelTaskScope in use-subagents-panel.ts).
*/
export function taskStartCount(items: readonly ChatItem[]): number {
let n = 0;
for (const item of items) {
if (item.kind === "user_text" || item.kind === "user_image") n += 1;
}
return n;
}
/** Lenient `agent_id` extraction from run_subagent arguments (complete JSON by the time a child is bound; unparseable/absent → null). */
export function agentIdFromRunSubagentArgs(argsJson: string): string | null {
try {
const parsed: unknown = JSON.parse(argsJson);
if (parsed !== null && typeof parsed === "object") {
const id = (parsed as Record<string, unknown>)["agent_id"];
if (typeof id === "string" && id.length > 0) return id;
}
} catch {
// Arguments still streaming or malformed: no agent id to offer.
}
return null;
}
/** Extract the latest Task's spawn tree: root first, then children in DFS preorder (document order). */
export function extractTopology(
model: StreamModel,
rootSessionId: string,
taskRunning: boolean,
): TopologyNode[] {
return extractFromSlice(model.items.slice(latestTaskStart(model.items)), rootSessionId, {
running: taskRunning,
});
}
/**
* Historical variant: extract the spawn tree of the Task slice CONTAINING the given direct
* child — the slice around the main-stream item that references it (a bound run_subagent card
* or a standalone SubagentItem), bounded by the nearest user_text/user_image on either side.
* Null when no main-stream item references the child (e.g. a resync swapped in a fresh model) —
* the caller then falls back to the latest Task. The root's running state stays live only while
* the slice is still the open-ended latest Task; an older Task has ended by definition (the
* next user message closed it).
*/
export function extractTopologyForChild(
model: StreamModel,
rootSessionId: string,
taskRunning: boolean,
childSessionId: string,
): TopologyNode[] | null {
const items = model.items;
const at = items.findIndex(
(it) =>
(it.kind === "tool_call" && it.subagentSessionId === childSessionId) ||
(it.kind === "subagent" && it.sessionId === childSessionId),
);
if (at === -1) return null;
// Slice bounds: the nearest Task-starting user item at or before the reference (falling back
// to 0 on a mid-stream join with no user item), and the next one after it (end of stream for
// the latest Task). Same "starts a new Task" predicate as latestTaskStart.
let start = 0;
for (let i = at; i >= 0; i--) {
const kind = items[i]!.kind;
if (kind === "user_text" || kind === "user_image") {
start = i;
break;
}
}
let end = items.length;
for (let i = at + 1; i < items.length; i++) {
const kind = items[i]!.kind;
if (kind === "user_text" || kind === "user_image") {
end = i;
break;
}
}
return extractFromSlice(items.slice(start, end), rootSessionId, {
running: end === items.length && taskRunning,
});
}
/** Shared walker behind both extractions: builds the node list from one Task slice of the main items. */
function extractFromSlice(
slice: readonly ChatItem[],
rootSessionId: string,
root: { running: boolean },
): TopologyNode[] {
const nodes: TopologyNode[] = [
{
sessionId: rootSessionId,
agentId: null,
running: root.running,
depth: 0,
origin: [],
parentId: null,
},
];
const seen = new Set<string>([rootSessionId]);
const addChild = (
sessionId: string,
child: StreamModel,
running: boolean,
parentId: string,
parentOrigin: string[],
argsAgentId: string | null,
): void => {
if (seen.has(sessionId)) return;
seen.add(sessionId);
const origin = [...parentOrigin, sessionId];
// Elapsed-time sources (see the StreamModel stamp docs): message timestamps first — they
// survive a reload — with the live wall-clock stamps as the unparseable-timestamp fallback.
const startedMs = child.firstTsMs ?? child.firstSeenLocalMs;
const elapsedMs =
child.firstTsMs !== undefined && child.lastActivityTsMs !== undefined
? Math.max(0, child.lastActivityTsMs - child.firstTsMs)
: child.firstSeenLocalMs !== undefined && child.lastActivityLocalMs !== undefined
? Math.max(0, child.lastActivityLocalMs - child.firstSeenLocalMs)
: undefined;
nodes.push({
sessionId,
agentId: child.meta?.agentId ?? argsAgentId,
running,
depth: origin.length,
origin,
parentId,
...(startedMs !== undefined ? { startedMs } : {}),
...(elapsedMs !== undefined ? { elapsedMs } : {}),
});
// Deeper spawns live in the child model's own items (a child conversation is never Task-sliced — all of it belongs to this spawn).
walk(child.items, sessionId, origin);
};
const walk = (items: readonly ChatItem[], parentId: string, parentOrigin: string[]): void => {
for (const item of items) {
if (item.kind === "tool_call" && item.subagent && item.subagentSessionId) {
addChild(
item.subagentSessionId,
item.subagent,
!item.outputComplete,
parentId,
parentOrigin,
agentIdFromRunSubagentArgs(item.argumentsText),
);
} else if (item.kind === "subagent") {
addChild(item.sessionId, item.model, false, parentId, parentOrigin, null);
}
}
};
walk(slice, rootSessionId, []);
return nodes;
}
/**
* Whether the LATEST Task's slice references any spawned child (a bound run_subagent card or a
* standalone SubagentItem). The chat page's auto-open trigger: combined with the running task
* state, it fires only for a LIVE spawn in the current turn — children from earlier Tasks (or a
* finished conversation opened from history) never pop the panel open.
*/
export function latestTaskHasSubagent(model: StreamModel): boolean {
const items = model.items;
for (let i = latestTaskStart(items); i < items.length; i++) {
const item = items[i]!;
if ((item.kind === "tool_call" && item.subagent !== undefined) || item.kind === "subagent") {
return true;
}
}
return false;
}
/** Walk the nested-model chain (`origin` ends with the target's own session id); null when a hop is missing or the chain is empty. */
export function modelAtOrigin(model: StreamModel, origin: readonly string[]): StreamModel | null {
if (origin.length === 0) return null;
let cur: StreamModel = model;
for (const hop of origin) {
const next = cur.subagents.get(hop);
if (!next) return null;
cur = next;
}
return cur;
}
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
/** Fixed node box (avatar + truncated name + elapsed time + status glyph) — no text measurement, so the layout stays pure. */
export const NODE_W = 168;
export const NODE_H = 34;
export const GAP_X = 32;
export const GAP_Y = 10;
export const PAD = 6;
export interface PlacedNode {
node: TopologyNode;
/** Top-left corner of the node box (CSS pixels within the layout canvas). */
x: number;
y: number;
}
export interface PlacedEdge {
fromId: string;
toId: string;
/** SVG path between the two node midlines: straight when aligned, orthogonal elbow otherwise. */
path: string;
}
export interface TopologyLayout {
width: number;
height: number;
nodes: PlacedNode[];
edges: PlacedEdge[];
}
/** Layered tree layout over extractTopology's output (root included; child order = extraction order). */
export function layoutTopology(nodes: readonly TopologyNode[]): TopologyLayout {
const childrenOf = new Map<string, TopologyNode[]>();
for (const n of nodes) {
if (n.parentId === null) continue;
const list = childrenOf.get(n.parentId);
if (list) list.push(n);
else childrenOf.set(n.parentId, [n]);
}
// Leaves take successive rows; a parent centers on its first and last child (rows can be fractional).
const rowOf = new Map<string, number>();
let nextLeafRow = 0;
const assignRow = (node: TopologyNode): number => {
const kids = childrenOf.get(node.sessionId) ?? [];
let row: number;
if (kids.length === 0) {
row = nextLeafRow++;
} else {
const rows = kids.map(assignRow);
row = (rows[0]! + rows[rows.length - 1]!) / 2;
}
rowOf.set(node.sessionId, row);
return row;
};
for (const n of nodes) if (n.parentId === null) assignRow(n);
const placed: PlacedNode[] = nodes.map((node) => ({
node,
x: PAD + node.depth * (NODE_W + GAP_X),
y: PAD + (rowOf.get(node.sessionId) ?? 0) * (NODE_H + GAP_Y),
}));
const byId = new Map(placed.map((p) => [p.node.sessionId, p]));
const edges: PlacedEdge[] = [];
for (const p of placed) {
if (p.node.parentId === null) continue;
const from = byId.get(p.node.parentId);
if (!from) continue;
const x1 = from.x + NODE_W;
const y1 = from.y + NODE_H / 2;
const x2 = p.x;
const y2 = p.y + NODE_H / 2;
const midX = x1 + GAP_X / 2;
edges.push({
fromId: from.node.sessionId,
toId: p.node.sessionId,
path: y1 === y2 ? `M ${x1} ${y1} H ${x2}` : `M ${x1} ${y1} H ${midX} V ${y2} H ${x2}`,
});
}
const maxDepth = nodes.reduce((acc, n) => Math.max(acc, n.depth), 0);
const rows = Math.max(1, nextLeafRow);
return {
width: PAD * 2 + (maxDepth + 1) * NODE_W + maxDepth * GAP_X,
height: PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y,
nodes: placed,
edges,
};
}
// ---------------------------------------------------------------------------
// Display-name resolution
// ---------------------------------------------------------------------------
/**
* Resolve a node's display label from the loaded lists: agent id (session_meta capture /
* run_subagent argument, else the session list row's agentId) → the project agents list's
* display name (name, with an EMPTY name treated as missing, falling back to the agent id
* itself) → the child session's title.
* Null when nothing is known — the caller falls back to a generic label plus the short session id.
*/
export function resolveAgentLabel(
node: { sessionId: string; agentId: string | null },
agents: readonly { agentId: string; name?: string }[],
sessions: readonly { sessionId: string; agentId: string; title?: string }[],
): string | null {
const row = sessions.find((s) => s.sessionId === node.sessionId);
const agentId = node.agentId ?? row?.agentId ?? null;
if (agentId !== null) {
const agent = agents.find((a) => a.agentId === agentId);
return agent ? (agent.name?.length ? agent.name : agent.agentId) : agentId;
}
return row?.title ?? null;
}
/** Short session-id suffix for chips and node subtitles (ids end in a hex run — the tail is the distinctive part). */
export function shortSessionId(sessionId: string): string {
return sessionId.length > 8 ? sessionId.slice(-8) : sessionId;
}
+132 -6
View File
@@ -1,10 +1,13 @@
/**
* Chat page (refactored version):
* a thin top toolbar (Session title / status / iconized stats / Files toggle / details popup) +
* the message stream and input area (input box vertically centered when there are no messages).
* a thin top toolbar (Session title / status / iconized stats / Agents + Files toggles /
* details popup) + the message stream and input area (input box vertically centered when there
* are no messages).
* Files is no longer a mutually exclusive tab — it's a persistent, closable, resizable docked
* panel on the right (use-files-panel.ts), and each message's trailing file summary card jumps to
* and locates the file in the tree via onOpenFile.
* and locates the file in the tree via onOpenFile. The subagents panel docks the same way
* (use-subagents-panel.ts): subagent chips in the stream open it focused via onOpenSubagent,
* and the two docked panels are mutually exclusive (coordinated here, not in the hooks).
* Approval mode and Model/context usage live in the input area's toolbar; context is compacted
* via the /compact slash command.
* Draft state (/chat/new) is carried by DraftView: Agent / Workspace / approval mode / Model are
@@ -52,6 +55,7 @@ import { EmptyState } from "../../components/ui/empty-state";
import { toastError } from "../../components/ui/toast";
import { MessageStream } from "./message-stream";
import type { StreamRenderContext } from "./message-stream";
import { latestTaskHasSubagent, taskStartCount } from "./agent-topology";
import { ChatInput } from "./chat-input";
import { DraftView } from "./draft-view";
import { GoalStatusBanner } from "./goal-banner";
@@ -60,6 +64,14 @@ import { sameModelRef } from "../models/model-grouping";
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
import { FilesPanel } from "./files-panel";
import { useFilesPanel } from "./use-files-panel";
import type { FilesPanelState } from "./use-files-panel";
import { SubagentsPanel } from "./subagents-panel";
import {
advancePanelTaskScope,
createPanelTaskScope,
useSubagentsPanel,
} from "./use-subagents-panel";
import type { SubagentsPanelState } from "./use-subagents-panel";
import { useSessionDraft } from "./use-session-draft";
import { useSessionStream } from "./use-session-stream";
@@ -234,7 +246,29 @@ export function ChatPage() {
const [turnThinkingLevel, setTurnThinkingLevel] = useState("");
const routeSessionId = params.sessionId ?? null;
const filesPanel = useFilesPanel(routeSessionId);
const filesPanelRaw = useFilesPanel(routeSessionId);
const subagentsPanelRaw = useSubagentsPanel(routeSessionId);
// The two docked panels are MUTUALLY EXCLUSIVE — side by side they'd crush the chat column at
// the 1024px breakpoint (and two stacked Sheets on mobile would be worse). Exclusivity is
// enforced here, on the panel objects every consumer receives, rather than at each call site:
// ANY path that opens one panel (toolbar toggles, message file cards via onOpenFile, subagent
// chips via onOpenSubagent, or a future caller) closes the other as a side effect of
// setOpen(true). Closing never cascades. The hooks stay uncoordinated on purpose — they don't
// know about each other; only this page, which owns both, does.
const filesPanel: FilesPanelState = {
...filesPanelRaw,
setOpen: (next: boolean) => {
if (next) subagentsPanelRaw.setOpen(false);
filesPanelRaw.setOpen(next);
},
};
const subagentsPanel: SubagentsPanelState = {
...subagentsPanelRaw,
setOpen: (next: boolean) => {
if (next) filesPanelRaw.setOpen(false);
subagentsPanelRaw.setOpen(next);
},
};
const draft = routeSessionId === DRAFT_SESSION_ID;
const selected = draft ? null : (sessions.find((s) => s.sessionId === routeSessionId) ?? null);
// Currently effective model (session state, the model reference comes from the Session DTO): model selection in draft state is handled internally by DraftView.
@@ -276,6 +310,41 @@ export function ChatPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSessionId, selectedAgentId, setCurrentAgentId]);
// TASK-SCOPED panel visibility (owner rule: an open panel belongs to the task it was opened
// for). The pure tracker (advancePanelTaskScope, unit-tested) decides at each observation:
// - entering a session / a NEW Task starting (a user message — taskStartCount increase)
// closes the panel by default, so an unrelated task never inherits it;
// - the CURRENT task's first live spawn auto-opens it (re-armed per task; a manual close
// afterwards is respected until the next boundary).
// The auto-open applies only when docked (a mobile Sheet sliding over the conversation
// uninvited would be worse than staying discoverable via the row), never over an open Files
// panel (an automatic open must not steal an explicit one — the row and the toolbar's amber
// dot still signal), and never re-triggers an already-open panel (that would yank a pinned
// historical graph back to the latest Task); the tracker consumes the attempt regardless.
const panelTaskScopeRef = useRef(createPanelTaskScope());
const taskCount = taskStartCount(stream.model.items);
const liveSpawn = stream.taskState !== "idle" && latestTaskHasSubagent(stream.model);
useEffect(() => {
const action = advancePanelTaskScope(panelTaskScopeRef.current, {
sessionId: selectedSessionId,
taskCount,
liveSpawn,
});
if (action === "close") {
subagentsPanelRaw.setOpen(false);
} else if (
action === "autoOpen" &&
subagentsPanelRaw.isDocked &&
!subagentsPanelRaw.open &&
!filesPanelRaw.open
) {
subagentsPanelRaw.setOpen(true);
}
// The panel objects are rebuilt every render; the tracker only acts on real transitions of
// these three observed values.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSessionId, taskCount, liveSpawn]);
// Skills installed on the session's Agent (candidates for the input area's skill dropdown):
// fetched keyed on the session's Agent; on switch, cleared first (which also clears the input
// area's selection) before refetching; a failed fetch is silently treated as no skills.
@@ -731,14 +800,27 @@ export function ChatPage() {
onOpenFile: (path) => {
// The file card has already normalized the text path to a Workspace-relative path
// (toWorkspaceRelative, including stripping absolute-path prefixes and converting Windows
// separators), so this just opens the panel and navigates to it directly.
// separators), so this just opens the panel and navigates to it directly (the wrapped
// setOpen already closes the subagents panel — see the exclusivity block above).
filesPanel.setOpen(true);
filesPanel.browsePath(path);
},
onOpenSubagent: (sessionId, origin) => {
// Chip click: open the panel focused on that child (the focus chain ends with the child's
// own id; the wrapped setOpen closes the Files panel). focusSubagent after setOpen: the
// open resets the Task scope to "latest", and the focus then pins it to this chip's Task.
subagentsPanel.setOpen(true);
subagentsPanel.focusSubagent(sessionId, [...origin, sessionId]);
},
workspace: selected?.workspace ?? null,
statFiles,
};
// Any pending approval sitting inside a subagent (approvalKey = "originChain toolCallId";
// main-session keys start with a space): surfaces an amber dot on the toolbar button so a
// nested approval stays discoverable while the panel is closed.
const anySubagentPending = [...stream.pendingApprovals.keys()].some((k) => !k.startsWith(" "));
if (!projectId || !agentId) {
return (
<div className="p-6">
@@ -862,7 +944,41 @@ export function ChatPage() {
<StatChip icon={STAT_ICONS.elapsed} value={hs.elapsedNode} label={S.chat.statElapsed} />
</div>
{/* Files panel toggle: docks on the right of the chat instead of replacing it full-screen (use-files-panel.ts). */}
{/* Subagents panel toggle: latest-Task call graph + child conversations dock on the right (use-subagents-panel.ts); opening closes the Files panel (wrapped setOpen). */}
<button
type="button"
aria-expanded={subagentsPanel.open}
onClick={() => subagentsPanel.setOpen(!subagentsPanel.open)}
title={S.chat.openAgents}
className={`flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors duration-150 ${
subagentsPanel.open
? "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
}`}
>
{/* Nodes/network glyph (spawn tree). */}
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden
>
<circle cx="5" cy="12" r="2.5" />
<circle cx="19" cy="5.5" r="2.5" />
<circle cx="19" cy="18.5" r="2.5" />
<path d="M7.4 11 16.7 6.6M7.4 13l9.3 4.4" />
</svg>
{S.chat.openAgents}
{/* A pending approval inside a subagent: amber dot (the chip in the stream carries the accessible announcement). */}
{anySubagentPending && (
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-amber-500" />
)}
</button>
{/* Files panel toggle: docks on the right of the chat instead of replacing it full-screen (use-files-panel.ts); opening closes the subagents panel (wrapped setOpen). */}
<button
type="button"
aria-expanded={filesPanel.open}
@@ -1038,6 +1154,16 @@ export function ChatPage() {
)}
</div>
{selected && (
<SubagentsPanel
session={selected}
panel={subagentsPanel}
model={stream.model}
version={stream.version}
taskRunning={stream.taskState !== "idle"}
ctx={ctx}
/>
)}
{selected && <FilesPanel session={selected} panel={filesPanel} />}
</div>
@@ -17,7 +17,7 @@ import { ZoomableImage } from "../../components/ui/image-zoom";
import { MessageFilesCard } from "./message-files-card";
import { ThinkingBlock } from "./thinking-block";
import { ToolCallCard } from "./tool-call-card";
import { SubagentCard } from "./subagent-card";
import { SubagentChip } from "./subagent-chip";
import { CompactionBanner } from "./compaction-banner";
import { GoalRoundBanner } from "./goal-banner";
import { HandoffBanner, ModelSwitchBanner } from "./handoff-banner";
@@ -315,9 +315,11 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
case "tool_call":
return <ToolCallCard item={item} ctx={ctx} />;
case "subagent":
// Standalone child session (no run_subagent card to bind to): same full-width shortcut
// bar as the bound site — the conversation itself lives in the subagents panel.
return (
<div className="anim-msg my-2">
<SubagentCard sessionId={item.sessionId} model={item.model} running={false} ctx={ctx} />
<SubagentChip sessionId={item.sessionId} model={item.model} running={false} ctx={ctx} />
</div>
);
case "abort":
@@ -43,6 +43,8 @@ export interface StreamRenderContext {
onGiveUp?: () => void;
/** Opens the Files panel and navigates to this file (triggered by clicking the file-summary card at the end of a message; takes a Workspace-relative path); the card doesn't render if this isn't wired up. */
onOpenFile?: (path: string) => void;
/** Opens the subagents panel focused on this child session (subagent chip click); `origin` is the ctx.origin at the chip's render level — the child's ancestor chain, excluding its own id. */
onOpenSubagent?: (sessionId: string, origin: string[]) => void;
/** Absolute Workspace path of the current Session (used by the file-summary card to normalize body paths). */
workspace?: string | null;
/** Batch file-existence check (with session-level caching); the card doesn't render if this isn't wired up. */
@@ -1,71 +0,0 @@
/**
* Subagent card: nested inside a run_subagent card (or as a
* standalone card), renders the subsession's full streaming process by following the origin
* chain — expanded by default while running, collapsed once finished; nesting inside the card
* uses the same rendering recursively.
*/
import { useEffect, useRef, useState } from "react";
import { S } from "../../lib/strings";
import type { StreamModel } from "../../lib/omni/stream-model";
import { Chevron } from "../../components/ui/chevron";
import { MessageItems } from "./message-stream";
import type { StreamRenderContext } from "./message-stream";
export function SubagentCard({
sessionId,
model,
running,
ctx,
}: {
sessionId: string;
model: StreamModel;
running: boolean;
ctx: StreamRenderContext;
}) {
const [open, setOpen] = useState(running);
const userToggled = useRef(false);
const wasRunning = useRef(running);
// Append this card's subsession id to the nesting level: tool cards within the card match
// pending approvals by the full origin chain; the running state inside the subsession follows
// this card's own running prop, not the parent session's taskRunning.
const nestedCtx: StreamRenderContext = {
...ctx,
...(sessionId ? { origin: [...ctx.origin, sessionId] } : {}),
taskRunning: running,
};
useEffect(() => {
// Auto-collapse once finished (respects the user's choice if they manually toggled it).
if (wasRunning.current && !running && !userToggled.current) setOpen(false);
wasRunning.current = running;
}, [running]);
return (
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50/60 dark:border-gray-700 dark:bg-gray-900/40">
<button
type="button"
onClick={() => {
userToggled.current = true;
setOpen((v) => !v);
}}
className="flex w-full items-center gap-2 rounded-t-md px-3 py-1.5 text-left text-xs text-gray-500 transition-colors duration-200 hover:bg-gray-100/70 dark:text-gray-400 dark:hover:bg-gray-800/50"
>
<Chevron open={open} />
<span className="font-semibold text-gray-700 dark:text-gray-300">{S.chat.subagent}</span>
{sessionId && <span className="truncate font-mono text-gray-400">{sessionId}</span>}
{running && (
<span className="ml-auto flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span className="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-gray-400 border-t-transparent" />
{S.chat.subagentRunning}
</span>
)}
</button>
{open && (
<div className="anim-fade border-t border-dashed border-gray-200 px-3 py-1 dark:border-gray-800">
<MessageItems items={model.items} ctx={nestedCtx} />
</div>
)}
</div>
);
}
@@ -0,0 +1,72 @@
/**
* Subagent shortcut row: the only trace a spawned child session leaves in the main chat flow
* (the full nested conversation lives in the subagents side panel). A full-width bar styled
* like the stream's other collapsed rows (the thinking block / work-group header idiom:
* rounded border, muted background, left-aligned content) — agent avatar + resolved agent name
* + short session-id suffix — with a spinner while the child runs and an amber dot (also
* announced in the accessible name) while any approval is pending anywhere in the child's
* subtree, so a nested approval stays discoverable with the panel closed. Clicking opens the
* panel focused on this child via ctx.onOpenSubagent.
*/
import { S } from "../../lib/strings";
import { hasPendingWithinOrigin } from "../../lib/omni/stream-model";
import type { StreamModel } from "../../lib/omni/stream-model";
import { AgentAvatar } from "../../components/ui/agent-avatar";
import { useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
import { resolveAgentLabel, shortSessionId } from "./agent-topology";
import type { StreamRenderContext } from "./message-stream";
export function SubagentChip({
sessionId,
model,
running,
ctx,
agentId = null,
}: {
sessionId: string;
model: StreamModel;
running: boolean;
ctx: StreamRenderContext;
/** Agent id from the spawning call's arguments (bound tool-card site); the child's own session_meta capture takes priority. */
agentId?: string | null;
}) {
const { agents } = useProject();
const { sessions } = useSessions();
const label =
resolveAgentLabel({ sessionId, agentId: model.meta?.agentId ?? agentId }, agents, sessions) ??
S.chat.subagent;
const pending = hasPendingWithinOrigin(ctx.pendingApprovals.keys(), [...ctx.origin, sessionId]);
const name = `${S.chat.subagent} ${label}${pending ? ` · ${S.chat.approvalWaiting}` : ""}`;
return (
// Full-width bar, same visual family as the work-group header / thinking row (rounded-md
// border + muted background + px-3 py-2 left-aligned content) — not a compact pill, so the
// row lines up with the stream's other collapsed step rows.
<button
type="button"
aria-label={name}
title={name}
onClick={() => ctx.onOpenSubagent?.(sessionId, ctx.origin)}
className="flex w-full items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-left transition-colors duration-150 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900/60 dark:hover:bg-gray-800/60"
>
<AgentAvatar id={model.meta?.agentId ?? agentId ?? sessionId} name={label} size={16} />
<span className="min-w-0 truncate text-xs font-medium text-gray-700 dark:text-gray-300">
{label}
</span>
{sessionId && (
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
{shortSessionId(sessionId)}
</span>
)}
{running && (
<span
aria-hidden
className="inline-block h-2.5 w-2.5 shrink-0 animate-spin rounded-full border border-gray-400 border-t-transparent"
/>
)}
{pending && <span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500" />}
<span className="min-w-0 flex-1" />
</button>
);
}
@@ -0,0 +1,108 @@
/**
* Subagents panel shell (same docked/Sheet split as files-panel.tsx, which documents the
* clipping-window and inert rationale in detail): on desktop (≥1024px) it docks to the right of
* the chat with a drag-to-resize edge; on narrower viewports it becomes a bottom Sheet. Content
* is SubagentsView (latest-Task call graph + the selected child conversation), keyed by session
* so node selection resets on session switch.
*/
import type { SessionInfo } from "@prismshadow/penguin-server/api";
import { S } from "../../lib/strings";
import type { StreamModel } from "../../lib/omni/stream-model";
import { Sheet } from "../../components/ui/sheet";
import { SubagentsView } from "./subagents-view";
import type { StreamRenderContext } from "./message-stream";
import type { SubagentsPanelState } from "./use-subagents-panel";
function CloseIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" aria-hidden>
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export function SubagentsPanel({
session,
panel,
model,
version,
taskRunning,
ctx,
}: {
session: SessionInfo;
panel: SubagentsPanelState;
model: StreamModel;
version: number;
taskRunning: boolean;
ctx: StreamRenderContext;
}) {
const view = (
<SubagentsView
key={session.sessionId}
session={session}
model={model}
version={version}
taskRunning={taskRunning}
ctx={ctx}
focusRequest={panel.focusRequest}
taskScope={panel.taskScope}
/>
);
if (!panel.isDocked) {
return (
<Sheet
open={panel.open}
snap={panel.sheetSnap}
onSnapChange={panel.setSheetSnap}
onClose={() => panel.setOpen(false)}
title={S.subagentPanel.title}
>
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">{view}</div>
</div>
</Sheet>
);
}
return (
<>
{panel.open && (
<div
onMouseDown={panel.startResize}
onDoubleClick={panel.resetWidth}
title={S.files.resizeHandle}
className={`w-1.5 shrink-0 cursor-col-resize transition-colors duration-150 hover:bg-brand-300/50 dark:hover:bg-brand-700/40 ${
panel.resizing ? "bg-brand-400/60" : "bg-transparent"
}`}
/>
)}
<div
ref={panel.panelRef}
style={{ width: panel.open ? panel.width : 0 }}
// Same inert + clipping-window handling as the Files panel (see files-panel.tsx).
inert={!panel.open}
className={`relative flex min-h-0 shrink-0 flex-col overflow-hidden border-l border-gray-200 dark:border-gray-800 ${
panel.resizing ? "pointer-events-none" : "transition-[width] duration-200"
}`}
>
<div style={{ width: panel.width }} className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center gap-1 px-3 pt-2">
<h4 className="min-w-0 flex-1 truncate text-sm font-semibold">
{S.subagentPanel.title}
</h4>
<button
type="button"
onClick={() => panel.setOpen(false)}
title={S.common.close}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<CloseIcon />
</button>
</div>
<div className="min-h-0 flex-1">{view}</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,202 @@
/**
* Subagents panel content: one Task's call graph on top (root = the main session, one node per
* spawned child; clicking a node switches the conversation below) and the selected child's
* live conversation underneath, rendered with the SAME machinery as the main chat
* (MessageStream over the child StreamModel with ctx.origin set to the child's chain) — so
* streaming, nested tool cards, chips for deeper spawns, and pending approvals all keep
* working inside the panel.
*
* Which Task: the LATEST by default (toolbar open / session switch — a new Task then resets
* the graph to its own agents as it streams); a chip click pins `taskScope` to that chip's
* Task instead (use-subagents-panel.ts), so a chip on an older turn shows that turn's
* HISTORICAL graph via extractTopologyForChild — falling back to the latest Task when the
* anchor is no longer referenced (e.g. a resync swapped the model).
*
* Selection: an explicit chip click (focusRequest) or a node click wins; with no explicit
* choice the first child of the displayed Task is auto-focused (so opening the panel while a
* subagent is spawning immediately shows it). Selecting the root shows a note instead of
* duplicating the main transcript. A focused child outside the displayed Task keeps its
* conversation visible (found by walking the origin chain); the graph simply has no
* highlighted node.
*/
import { useEffect, useMemo, useState } from "react";
import type { SessionInfo } from "@prismshadow/penguin-server/api";
import { S } from "../../lib/strings";
import type { StreamModel } from "../../lib/omni/stream-model";
import { AgentAvatar } from "../../components/ui/agent-avatar";
import { EmptyState } from "../../components/ui/empty-state";
import { StatusIcon } from "../../components/ui/status-icon";
import { useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
import {
extractTopology,
extractTopologyForChild,
modelAtOrigin,
resolveAgentLabel,
shortSessionId,
} from "./agent-topology";
import type { TopologyNode } from "./agent-topology";
import { AgentTopologyView } from "./agent-topology-view";
import { MessageStream } from "./message-stream";
import type { StreamRenderContext } from "./message-stream";
interface Selection {
sessionId: string;
/** ctx.origin chain of the selected conversation (ends with its own session id; empty = the root/main session). */
origin: string[];
}
export function SubagentsView({
session,
model,
version,
taskRunning,
ctx,
focusRequest,
taskScope,
}: {
session: SessionInfo;
model: StreamModel;
/** View-model version (repaint signal): keys the topology recompute and drives MessageStream's auto-follow. */
version: number;
taskRunning: boolean;
/** Main-session render context (origin []): the child conversation derives its own from it. */
ctx: StreamRenderContext;
focusRequest: Selection | null;
/** Displayed Task scope (see use-subagents-panel.ts): null = latest; an anchor pins the historical Task containing that child. */
taskScope: { anchorSessionId: string } | null;
}) {
const { agents } = useProject();
const { sessions } = useSessions();
const [selected, setSelected] = useState<Selection | null>(null);
// A chip click focuses that child (fresh-object identity: re-clicking the same chip re-fires).
useEffect(() => {
if (focusRequest) {
setSelected({ sessionId: focusRequest.sessionId, origin: focusRequest.origin });
}
}, [focusRequest]);
const nodes = useMemo(
() =>
// A pinned scope (chip click) shows THAT Task's graph — historical or latest alike; when
// its anchor is no longer referenced in the stream, fall back to the latest Task.
(taskScope
? extractTopologyForChild(model, session.sessionId, taskRunning, taskScope.anchorSessionId)
: null) ?? extractTopology(model, session.sessionId, taskRunning),
// version is the model's change signal (items mutate in place).
// eslint-disable-next-line react-hooks/exhaustive-deps
[model, version, session.sessionId, taskRunning, taskScope],
);
// No explicit selection yet: auto-focus the displayed Task's first child (null when none spawned).
const firstChild = nodes.find((n) => n.depth > 0) ?? null;
const active: Selection | null =
selected ??
(firstChild ? { sessionId: firstChild.sessionId, origin: firstChild.origin } : null);
const labelFor = (node: TopologyNode): string => {
if (node.depth === 0) {
const agent = agents.find((a) => a.agentId === session.agentId);
// Same empty-name rule as resolveAgentLabel: an empty display name falls back to the id.
return agent ? (agent.name?.length ? agent.name : agent.agentId) : session.agentId;
}
// Last-resort label is the short session id, not a generic word: two unknown children must stay distinguishable in the graph.
return resolveAgentLabel(node, agents, sessions) ?? shortSessionId(node.sessionId);
};
const isRoot = active !== null && active.origin.length === 0;
const activeNode = active ? (nodes.find((n) => n.sessionId === active.sessionId) ?? null) : null;
const activeModel = active && !isRoot ? modelAtOrigin(model, active.origin) : null;
const activeLabel = activeNode
? labelFor(activeNode)
: active
? (resolveAgentLabel(
{ sessionId: active.sessionId, agentId: activeModel?.meta?.agentId ?? null },
agents,
sessions,
) ?? S.chat.subagent)
: "";
const activeRunning = activeNode?.running ?? false;
// Same derivation the subagent card used: the child conversation's approval keys and
// "Reasoning & Tools" running state follow the child's chain and its own running flag.
const childCtx: StreamRenderContext | null = active
? { ...ctx, origin: active.origin, taskRunning: activeRunning }
: null;
return (
<div className="flex h-full min-h-0 flex-col">
{/* Call graph of the displayed Task — latest by default, a chip's Task when pinned (capped height; scrolls both ways for deep/wide trees). */}
<div className="shrink-0 border-b border-gray-200 px-3 pb-2 pt-1.5 dark:border-gray-800">
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{S.subagentPanel.topologyLabel}
</p>
{nodes.length > 1 ? (
<div className="max-h-48 overflow-y-auto">
<AgentTopologyView
nodes={nodes}
selectedId={active?.sessionId ?? null}
labelFor={labelFor}
onSelect={(node) => setSelected({ sessionId: node.sessionId, origin: node.origin })}
/>
</div>
) : (
<p className="py-1 text-xs text-gray-400 dark:text-gray-500">{S.subagentPanel.empty}</p>
)}
</div>
{/* Selected conversation (or the root note / empty state). */}
{active === null ? (
<div className="min-h-0 flex-1">
<EmptyState title={S.subagentPanel.empty} />
</div>
) : isRoot ? (
<div className="flex min-h-0 flex-1 items-center justify-center p-4">
<p className="text-sm text-gray-400 dark:text-gray-500">
{S.subagentPanel.mainSessionNote}
</p>
</div>
) : activeModel === null ? (
// The chain broke (e.g. a resync swapped in a fresh model and this child hasn't re-streamed yet).
<div className="min-h-0 flex-1">
<EmptyState title={S.subagentPanel.empty} />
</div>
) : (
<>
{/* Slim identity strip for the conversation below. */}
<div className="flex shrink-0 items-center gap-2 border-b border-gray-100 px-3 py-1.5 dark:border-gray-800/60">
<AgentAvatar
id={activeModel.meta?.agentId ?? active.sessionId}
name={activeLabel}
size={16}
/>
<span className="min-w-0 truncate text-xs font-semibold text-gray-700 dark:text-gray-300">
{activeLabel}
</span>
<span
title={active.sessionId}
className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500"
>
{shortSessionId(active.sessionId)}
</span>
{activeRunning && (
<StatusIcon state="running" size={10} label={S.chat.subagentRunning} />
)}
</div>
<div className="min-h-0 flex-1">
{/* Keyed by child: switching nodes resets scroll-follow instead of carrying the old position over. */}
{childCtx && (
<MessageStream
key={active.sessionId}
items={activeModel.items}
version={version}
ctx={childCtx}
/>
)}
</div>
</>
)}
</div>
);
}
@@ -1,11 +1,10 @@
/**
* Tool call card: collapses to a single line by
* default — status icon + tool name + duration (a live-ticking timer while running) + status
* badge; clicking expands full arguments and output; a nested subagent renders below them
* regardless of collapsed state.
* The pending-approval row is always visible regardless of collapsed state; when a pending
* approval appears anywhere in a nested subagent chain, the card auto-expands once (respecting
* the user's choice if they've manually collapsed it since).
* badge; clicking expands full arguments and output; a bound subagent renders as a full-width
* shortcut row below them regardless of collapsed state (the child conversation itself lives
* in the subagents side panel; the row carries its own pending-approval dot, so the card no
* longer needs to auto-expand for nested approvals).
*
* Duration accounting = **argument-generation segment + execution segment** (excludes time
* spent waiting on human approval): the model streaming out arguments token by token is often
@@ -13,7 +12,7 @@
* understate this step's cost. While waiting on approval, the already-settled generation
* segment is shown, with a separate "Waiting for approval" badge attached.
*/
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { S } from "../../lib/strings";
import { humanizeDuration } from "../../lib/format";
import { approvalKey } from "../../lib/omni/stream-model";
@@ -25,7 +24,8 @@ import { StatusIcon } from "../../components/ui/status-icon";
import type { RunState } from "../../components/ui/status-icon";
import { ApprovalButtons } from "./approval-buttons";
import { LiveDuration } from "./live-duration";
import { SubagentCard } from "./subagent-card";
import { agentIdFromRunSubagentArgs } from "./agent-topology";
import { SubagentChip } from "./subagent-chip";
import type { StreamRenderContext } from "./message-stream";
/** Tools that accept the optional model-written `description` argument. */
@@ -172,21 +172,6 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
// Matched by the current origin chain + toolCallId: prevents parent/child session tool_call_id collisions from lighting each other up.
const pending = ctx.pendingApprovals.get(approvalKey(ctx.origin, item.toolCallId));
// Whether there's a pending approval anywhere in the nested subagent chain (keys are `origin-chain toolCallId`, matched by prefix at any depth).
const nestedPrefix = item.subagentSessionId
? [...ctx.origin, item.subagentSessionId].join("/")
: null;
const hasNestedPending =
nestedPrefix !== null &&
[...ctx.pendingApprovals.keys()].some(
(k) => k.startsWith(`${nestedPrefix} `) || k.startsWith(`${nestedPrefix}/`),
);
// A nested pending approval needs the user's action: auto-expand once (respected if the user manually collapses it afterward).
useEffect(() => {
if (hasNestedPending && !userToggled.current) setOpen(true);
}, [hasNestedPending]);
const preview = previewArguments(item.name, item.argumentsText);
const subtitle = headerSubtitle(item.name, item.argumentsText);
// Executing = the call has finished streaming, output hasn't arrived yet, and it's not waiting on approval (approval wait time doesn't count toward execution).
@@ -357,13 +342,14 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
</div>
)}
{/* Subagent card: always visible (not hidden after completion, only collapsed by default), unaffected by the tool card's collapsed state. Rendered below the expanded arguments/output so the nested conversation reads after the tool call's own content, not between the header and its details; pt-2 keeps a gap from the tinted args/output blocks above. */}
{/* Subagent row: always visible (unaffected by the tool card's collapsed state) below the expanded arguments/output — a full-width shortcut bar into the subagents panel; the nested conversation no longer renders inline. */}
{item.subagent && (
<div className="px-3 pb-2 pt-2">
<SubagentCard
<SubagentChip
sessionId={item.subagentSessionId ?? ""}
model={item.subagent}
running={!item.outputComplete}
agentId={agentIdFromRunSubagentArgs(item.argumentsText)}
ctx={ctx}
/>
</div>
@@ -0,0 +1,254 @@
/**
* Subagents panel state machine (cloned from use-files-panel.ts, which documents the shared
* mechanics in detail): panel open/close, drag-to-resize with its own persisted width key, the
* desktop-dock vs. mobile-Sheet breakpoint, the "focus this child conversation" command driven
* by clicking a subagent chip inside a message, and the displayed Task scope (latest vs. the
* historical Task a chip was clicked on — see taskScope).
*
* VISIBILITY IS TASK-SCOPED — this deliberately diverges from the Files panel's
* open-persists-across-sessions convention: an open panel belongs to the task it was opened
* for. Starting a new Task (a user message) closes it by default, entering a session starts
* closed, and it comes back only via a manual open (toolbar/chip) or the CURRENT task spawning
* a subagent (auto-open, re-armed per task). The pure tracker below
* (createPanelTaskScope/advancePanelTaskScope) owns those boundary decisions; the chat page
* observes the stream and applies its actions.
*
* Width remains a layout preference persisted to localStorage, and the focus command uses the
* fresh-object idiom so clicking the same chip again still re-triggers the panel's focus
* effect.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import type { SheetSnap } from "../../components/ui/sheet";
export interface SubagentsPanelState {
open: boolean;
setOpen: (open: boolean) => void;
/** Snap point for the mobile bottom Sheet; unused in the desktop docked state. */
sheetSnap: SheetSnap;
setSheetSnap: (snap: SheetSnap) => void;
/** Clicking a subagent chip: commands the panel to select this child conversation. `origin` is the full chain ending with the child's own session id. */
focusSubagent: (sessionId: string, origin: string[]) => void;
/** The external focus command produced by focusSubagent; a fresh object per call (identity-compared, so re-clicking the same chip re-triggers the effect). */
focusRequest: { sessionId: string; origin: string[] } | null;
/**
* Which Task's topology the panel displays. Null = the LATEST Task (the default: opening via
* the toolbar resets to it, and so does switching Sessions). A chip click pins the graph to
* the Task containing that chip instead — `anchorSessionId` is the clicked child's TOP-LEVEL
* ancestor (the first origin hop; for deeper chips inside the panel the referencing main-
* stream item belongs to that ancestor), which the view resolves to its Task slice via
* extractTopologyForChild, so a chip on an older turn shows that turn's historical graph.
*/
taskScope: { anchorSessionId: string } | null;
/** Docked at >=1024px (lg); otherwise a bottom Sheet — mounted mutually exclusively. */
isDocked: boolean;
width: number;
resizing: boolean;
startResize: (e: ReactMouseEvent<HTMLDivElement>) => void;
/** Double-clicking the drag handle: width reverts to the window-proportional default. */
resetWidth: () => void;
/** Ref to the docked panel's root node (drag-to-resize measures its right edge). */
panelRef: RefObject<HTMLDivElement | null>;
}
const MIN_WIDTH = 320;
const DOCK_QUERY = "(min-width: 1024px)";
const WIDTH_STORAGE_KEY = "penguin.subagentsPanelWidth";
// ---------------------------------------------------------------------------
// Task-scoped visibility tracker (pure — unit-tested in test/panel-task-scope.test.ts)
// ---------------------------------------------------------------------------
/**
* Mutable tracker state, held in a ref by the chat page. `taskCount` is the number of
* Task-starting user items observed in the session's stream (taskStartCount in
* agent-topology.ts); an increase IS the "user started a new Task" boundary.
*/
export interface PanelTaskScope {
sessionId: string | null;
taskCount: number;
/** Task ordinal (the taskCount value) whose one auto-open attempt was already consumed; null = armed. */
autoOpenedAt: number | null;
}
export function createPanelTaskScope(): PanelTaskScope {
return { sessionId: null, taskCount: 0, autoOpenedAt: null };
}
/**
* Advance the tracker with one observation of the current session's stream and return what the
* panel should do — the whole task-scoped lifecycle in one place:
* - session switch → "close" (a session is entered closed; no inherited open state), unless
* its CURRENT task already has a live spawn, which wins as "autoOpen" (a mid-run entry —
* reload included — counts as the spawn introducing itself);
* - a new Task (taskCount increase) → "close" by default (an unrelated task must not inherit
* an open panel) and RE-ARMS the auto-open;
* - a live spawn in the current task → "autoOpen", at most once per task — a manual close
* afterwards is respected until the next boundary;
* - anything else (steering, compaction, more messages in the same task) → null.
* A taskCount DECREASE is a defensive re-baseline (a resync swapped in a smaller model):
* adopted silently — no boundary, and the auto-open attempt counts as consumed so a rebuild
* can never surprise-reopen a panel the user closed mid-task.
* The caller applies "autoOpen" under its own layout guards (docked, files panel closed, not
* already open); the attempt is consumed here regardless, so a suppressed attempt never
* retriggers within the same task.
*/
export function advancePanelTaskScope(
state: PanelTaskScope,
obs: { sessionId: string | null; taskCount: number; liveSpawn: boolean },
): "close" | "autoOpen" | null {
const switched = obs.sessionId !== state.sessionId;
const newTask = !switched && obs.taskCount > state.taskCount;
const rebaseline = !switched && obs.taskCount < state.taskCount;
if (switched || obs.taskCount !== state.taskCount) {
state.sessionId = obs.sessionId;
state.taskCount = obs.taskCount;
// A real boundary re-arms the auto-open; the defensive re-baseline marks it consumed.
state.autoOpenedAt = rebaseline ? obs.taskCount : null;
}
if (obs.liveSpawn && state.autoOpenedAt !== state.taskCount) {
state.autoOpenedAt = state.taskCount;
return "autoOpen";
}
if (switched || newTask) return "close";
return null;
}
/** Width cap: at most half the window (keeping the chat column usable), plus a hard 720px readability ceiling. */
function maxWidthFor(windowWidth: number): number {
return Math.max(MIN_WIDTH, Math.min(720, Math.round(windowWidth * 0.5)));
}
/** Default width ≈ 1/3 of the window, clamped within the min/max bounds (same proportion as the Files panel). */
function defaultWidthFor(windowWidth: number): number {
return Math.min(maxWidthFor(windowWidth), Math.max(MIN_WIDTH, Math.round(windowWidth * 0.34)));
}
/** Initial width: stored preference (clamped back within the current window's bounds) over the proportional default. */
function initialWidth(): number {
const stored = Number(localStorage.getItem(WIDTH_STORAGE_KEY));
if (!Number.isFinite(stored) || stored <= 0) return defaultWidthFor(window.innerWidth);
return Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, Math.round(stored)));
}
export function useSubagentsPanel(sessionId: string | null): SubagentsPanelState {
const [open, setOpenRaw] = useState(false);
const [sheetSnap, setSheetSnap] = useState<SheetSnap>("half");
const [focusRequest, setFocusRequest] = useState<{
sessionId: string;
origin: string[];
} | null>(null);
const [taskScope, setTaskScope] = useState<{ anchorSessionId: string } | null>(null);
/**
* Opening defaults to browsing intent (Sheet at half) on the LATEST Task's graph;
* focusSubagent's conversation intent — queued in the same batch on the chip path — then
* promotes the snap to full and pins the scope to the chip's Task.
*/
const setOpen = useCallback((next: boolean) => {
if (next) {
setSheetSnap("half");
setTaskScope(null);
}
setOpenRaw(next);
}, []);
const [width, setWidth] = useState(initialWidth);
/** Synchronous mirror of width, read by the mouseup persist step (sidesteps the stale closure). */
const widthRef = useRef(width);
const [resizing, setResizing] = useState(false);
const [isDocked, setIsDocked] = useState(() => window.matchMedia(DOCK_QUERY).matches);
const panelRef = useRef<HTMLDivElement>(null);
// Switching Session resets the focus command and any pinned historical Task scope (both
// pointed into the old session's stream — the new session opens on its latest topology).
// The open/closed state is NOT touched here: visibility is task-scoped and owned by the
// chat page's tracker (advancePanelTaskScope above), which closes on session entry and new
// Tasks, and auto-opens on the current task's first live spawn.
useEffect(() => {
setFocusRequest(null);
setTaskScope(null);
}, [sessionId]);
useEffect(() => {
const mq = window.matchMedia(DOCK_QUERY);
const onChange = (e: MediaQueryListEvent) => setIsDocked(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
const focusSubagent = useCallback((sid: string, origin: string[]) => {
setSheetSnap("full"); // Conversation intent: the child transcript needs the space on mobile
// Pin the graph to this chip's Task (the top-level ancestor anchors the slice lookup) —
// ordered after the setOpen(true) of the same click, so the pin wins the batch.
setTaskScope({ anchorSessionId: origin[0] ?? sid });
setFocusRequest({ sessionId: sid, origin });
}, []);
// Drag-to-resize: identical handling to the Files panel (see use-files-panel.ts for the rationale comments).
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
const rect = panelRef.current?.getBoundingClientRect();
const right = rect ? rect.right : window.innerWidth;
const next = Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, right - e.clientX));
widthRef.current = next;
setWidth(next);
};
const onUp = () => {
setResizing(false);
localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(widthRef.current)));
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [resizing]);
const startResize = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
setResizing(true);
}, []);
const resetWidth = useCallback(() => {
const next = defaultWidthFor(window.innerWidth);
widthRef.current = next;
setWidth(next);
// Clear rather than write the default: the default keeps following the window's proportion.
localStorage.removeItem(WIDTH_STORAGE_KEY);
}, []);
// When the window shrinks, clamp the panel back within the cap (shrinks only, like the Files panel).
useEffect(() => {
const onResize = () => {
setWidth((w) => {
const clamped = Math.min(w, maxWidthFor(window.innerWidth));
if (clamped !== w) widthRef.current = clamped;
return clamped;
});
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return {
open,
setOpen,
sheetSnap,
setSheetSnap,
focusSubagent,
focusRequest,
taskScope,
isDocked,
width,
resizing,
startResize,
resetWidth,
panelRef,
};
}
+107 -10
View File
@@ -12,12 +12,13 @@
* **replaces** the fragment's content (guaranteeing consistency;
* with no open fragment — mid-stream join — it's appended directly); an
* orphan delta (no start seen) is ignored, converging once the complete message arrives.
* - origin routing: messages carrying an origin go into a SubagentCard;
* when a sub-session's session_meta arrives, it binds to "the most
* recent allowed (decision=allow) and not-yet-complete run_subagent tool
* card that hasn't been bound to an origin yet", creating a standalone
* SubagentCard if none is found; inside the card, the same reducer
* recurses (with the first origin hop stripped). Sub-session
* - origin routing: messages carrying an origin go into a nested child
* model (surfaced in the UI as a subagent chip + the subagents panel);
* on a sub-session's first message it binds to "the most recent allowed
* (decision=allow) and not-yet-complete run_subagent tool card that
* hasn't been bound to an origin yet", falling back to a standalone
* SubagentItem if none is found; inside the nested model, the same
* reducer recurses (with the first origin hop stripped). Sub-session
* token_usage counts toward this level's stats (same convention as the CLI).
* - Events: approval_decision annotates the corresponding tool card
* (labeled "manual" if clicked on this end, "automatic" otherwise);
@@ -59,6 +60,7 @@ import type {
EventPayload,
OmniMessage,
PartialModelPayload,
SessionMetaPayload,
StopReason,
TokenUsagePayload,
} from "@prismshadow/penguin-core/omnimessage";
@@ -269,10 +271,45 @@ export type ChatItem =
// Model state
// ---------------------------------------------------------------------------
/**
* Identity of a nested child session, captured from its own session_meta (a child session's
* DTO isn't loaded by the chat page, so this is the panel's only live source for "which agent
* runs this child"). Main-session models never fill it — their identity comes from the Session DTO.
*/
export interface NestedSessionMeta {
/** Agent id parsed from the `agent_state` path (its parent directory name); null when unparseable. */
agentId: string | null;
provider: string;
modelId: string;
/** Session origin as recorded by core (subagent / schedule); absent = user-created. */
source?: "subagent" | "schedule";
}
export interface StreamModel {
items: ChatItem[];
/** A nested sub-session model (produces no stats row; its stats count toward the parent). */
nested: boolean;
/** Child-session identity from its session_meta (nested models only; null until it arrives). */
meta: NestedSessionMeta | null;
/**
* Elapsed-time stamps for the subagents panel's topology nodes (nested models only — the main
* session's timing is covered by task stats). Stamped in routeNested: the `firstSeen` pair when
* the nested model is created, the `lastActivity` pair on every message routed into its subtree
* (cheap assignments). Two clocks are kept because neither works alone:
* - Local wall clock (`firstSeenLocalMs` / `lastActivityLocalMs`): when this client saw the
* child appear / last act. Faithful only while watching live — a history replay sets all of
* them within one synchronous load, so every replayed span collapses to ~0 at load time.
* - Message timestamps (`firstTsMs` / `lastActivityTsMs`): the same two moments in SERVER
* time, recorded identically during live streaming and history replay — a reload reproduces
* the same span. May drift from the local clock (same caveat as LiveDuration's sinceMs).
* Topology extraction therefore derives a done node's duration from the timestamp pair (correct
* in both live and reloaded views) and ticks a running node from firstTsMs, falling back to the
* local pair only when timestamps were unparseable (see agent-topology.ts).
*/
firstSeenLocalMs?: number;
lastActivityLocalMs?: number;
firstTsMs?: number;
lastActivityTsMs?: number;
stats: TaskStatsTracker;
/** The currently open text/thinking fragment (opened by start, closed by stop). */
openText: AssistantTextItem | null;
@@ -364,6 +401,7 @@ function newModel(nested: boolean, localDecisions: Set<string>): StreamModel {
return {
items: [],
nested,
meta: null,
stats: createTaskStatsTracker(),
openText: null,
openThinking: null,
@@ -456,9 +494,54 @@ export function pushMessage(
advanceLastTs(model, msg.timestamp);
return;
}
// session_meta (main session): not rendered as an item and carries nothing the view model
// needs — session_meta holds per-session invariants (identity/config), all surfaced through
// the Session DTO instead.
// session_meta: never rendered as an item. For the main session it carries nothing the view
// model needs (identity/config are all surfaced through the Session DTO). For a NESTED child
// session no DTO is loaded here, so capture the identity the subagents panel needs (which
// agent runs this child, on which model) — a rewritten session_meta (file rotation) simply
// overwrites with the same values.
if (msg.type === "session_meta" && model.nested) {
const p = msg.payload as SessionMetaPayload;
const meta: NestedSessionMeta = {
agentId: agentIdFromStatePath(p.agent_state),
provider: p.provider,
modelId: p.model_id,
};
if (p.source !== undefined) meta.source = p.source;
model.meta = meta;
}
}
/**
* Agent id from a session_meta `agent_state` path: the path is
* `<root>/<projectId>/agents/<agentId>/agent_state`, so the agent id is the parent directory
* name. Returns null when the path has no parent segment (defensive — core always writes the full path).
*/
export function agentIdFromStatePath(agentStatePath: string): string | null {
const segments = agentStatePath.split(/[\\/]/).filter((s) => s.length > 0);
return segments.length >= 2 ? segments[segments.length - 2]! : null;
}
/**
* Whether any pending approval sits at or below the given origin chain (prefix match on
* approvalKey): `chain` is the ancestor chain ending with the subtree root's own session id.
* Drives the subagent chip's amber dot and the toolbar badge — a nested approval must stay
* discoverable even though the child conversation lives in the side panel.
*/
export function hasPendingWithinOrigin(
pendingKeys: Iterable<string>,
chain: readonly string[],
): boolean {
const prefix = chain.join("/");
for (const key of pendingKeys) {
// The approvalKey delimiters are load-bearing here: a key is `origin.join("/") + " " +
// toolCallId`, so requiring the chain to be followed by a space (an approval on the subtree
// root itself) or a slash (one strictly below it) is what keeps a sibling whose id merely
// extends this chain ("c1" vs "c1x") from matching, and keeps a main-session key (leading
// space) from matching any chain. Refactoring approvalKey to another separator would
// silently break this predicate — change the two together.
if (key.startsWith(`${prefix} `) || key.startsWith(`${prefix}/`)) return true;
}
return false;
}
/** ISO timestamp → milliseconds (returns undefined if invalid). */
@@ -500,6 +583,8 @@ export function registerLocalDecision(model: StreamModel, toolCallId: string): v
* origin for the main session). A parent/child session's tool_call_id can
* collide, so the origin chain must be included to distinguish them and
* avoid lighting up the approval button on the wrong tool card.
* The "/" and " " separators are relied on by hasPendingWithinOrigin's prefix
* matching — keep the two in sync if this format ever changes.
*/
export function approvalKey(origin: readonly string[] | undefined, toolCallId: string): string {
return `${origin?.join("/") ?? ""} ${toolCallId}`;
@@ -1242,9 +1327,21 @@ function routeNested(model: StreamModel, msg: OmniMessage, nowMs: number): void
let sub = model.subagents.get(head);
if (!sub) {
sub = newModel(true, model.localDecisions);
sub.firstSeenLocalMs = nowMs;
model.subagents.set(head, sub);
bindSubagent(model, head, sub);
}
// Elapsed-time stamps (see the StreamModel field docs): every message routed into this child's
// subtree — its own or a deeper descendant's — counts as its activity; the recursive pushMessage
// below stamps the deeper hops the same way.
sub.lastActivityLocalMs = nowMs;
const activityTs = tsOf(msg.timestamp);
if (activityTs !== undefined) {
if (sub.firstTsMs === undefined) sub.firstTsMs = activityTs;
if (sub.lastActivityTsMs === undefined || activityTs > sub.lastActivityTsMs) {
sub.lastActivityTsMs = activityTs;
}
}
// Strip the first origin hop and recursively feed into the nested model.
const rest = msg.origin!.slice(1);
const forwarded: OmniMessage = { ...msg };
@@ -1256,7 +1353,7 @@ function routeNested(model: StreamModel, msg: OmniMessage, nowMs: number): void
/**
* Binding rule: bind to the most recent allowed
* (decision=allow) and not-yet-complete (output not yet complete)
* run_subagent tool card that hasn't been bound to an origin yet; append a standalone SubagentCard if none is found.
* run_subagent tool card that hasn't been bound to an origin yet; append a standalone SubagentItem if none is found.
*/
function bindSubagent(model: StreamModel, sessionId: string, sub: StreamModel): void {
for (let i = model.items.length - 1; i >= 0; i--) {
+11
View File
@@ -715,6 +715,7 @@ When done, open index.html in a browser and self-test once.`,
statsLabel: "Stats",
removeImage: "Remove image",
openWorkspace: "Open workspace",
openAgents: "Agents panel",
filesInMessage: (n: number) => `${n} ${n === 1 ? "file" : "files"}`,
openPreview: "Click to preview",
showMoreFiles: (n: number) => `Show ${n} more ${n === 1 ? "file" : "files"}`,
@@ -799,6 +800,16 @@ When done, open index.html in a browser and self-test once.`,
} as Record<string, string>,
},
/** Subagents side panel: call-graph of the latest Task + the selected child conversation. */
subagentPanel: {
title: "Agents panel",
topologyLabel: "Call graph",
mainSessionNote: "The main conversation stays in the chat area",
empty: "No subagents in the current task yet",
nodeRunning: "running",
nodeDone: "done",
},
files: {
title: "Files",
upload: "Upload",
+11
View File
@@ -697,6 +697,7 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
statsLabel: "统计信息",
removeImage: "移除图片",
openWorkspace: "打开工作区",
openAgents: "智能体面板",
/** File summary card at the end of a message (Codex-style): title, inline preview action, and collapsed row. */
filesInMessage: (n: number) => `${n} 个文件`,
openPreview: "点击预览",
@@ -778,6 +779,16 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
} as Record<string, string>,
},
/** Subagents side panel: call-graph of the latest Task + the selected child conversation. */
subagentPanel: {
title: "智能体面板",
topologyLabel: "调用关系",
mainSessionNote: "主会话请在对话区查看",
empty: "本次任务尚未派生子智能体",
nodeRunning: "运行中",
nodeDone: "已完成",
},
files: {
title: "文件",
upload: "上传",
+491
View File
@@ -0,0 +1,491 @@
/**
* agent-topology.ts unit tests: latest-Task extraction (spawns, deep spawns, dedupe on a
* repolled child, the task boundary at a new user message), the historical-Task variant
* (extractTopologyForChild slicing around an older chip's child), per-node elapsed-time
* sources (live wall-clock stamps vs. message timestamps across a history replay), identity
* resolution (session_meta capture vs. the run_subagent agent_id argument, agent_state path
* parsing), the pending-approval subtree predicate, and layered-tree layout
* determinism/geometry.
*/
import { describe, expect, it, vi } from "vitest";
import {
approvalDecision,
assistantText,
imageUrlMessage,
sessionMeta,
toolCall,
toolCallOutput,
userText,
withOrigin,
} from "@prismshadow/penguin-core/omnimessage";
import type { OmniMessage, SessionMetaPayload } from "@prismshadow/penguin-core/omnimessage";
import {
agentIdFromStatePath,
approvalKey,
createStreamModel,
hasPendingWithinOrigin,
pushMessage,
} from "../src/lib/omni/stream-model";
import type { StreamModel, SubagentItem } from "../src/lib/omni/stream-model";
import {
agentIdFromRunSubagentArgs,
extractTopology,
extractTopologyForChild,
GAP_X,
GAP_Y,
latestTaskHasSubagent,
latestTaskStart,
layoutTopology,
modelAtOrigin,
NODE_H,
NODE_W,
PAD,
resolveAgentLabel,
shortSessionId,
taskStartCount,
} from "../src/features/chat/agent-topology";
function meta(sessionId: string, agentState = "/a"): OmniMessage<SessionMetaPayload> {
return sessionMeta({
session_id: sessionId,
model_id: "m",
provider: "custom",
model_context_window: 200000,
system_prompt: "",
tools: [],
agent_state: agentState,
workspace: "/w",
source: "subagent",
});
}
/** Spawn a child bound to a run_subagent card: card + allow + first child-origin message. */
function spawnChild(m: StreamModel, toolCallId: string, sessionId: string, args = "{}"): void {
pushMessage(m, toolCall({ name: "run_subagent", arguments: args, toolCallId }));
pushMessage(m, approvalDecision("allow", toolCallId));
pushMessage(m, withOrigin(meta(sessionId), sessionId));
}
/** Override a message timestamp (the builders default to the current time). */
function at<M extends OmniMessage>(msg: M, ts: string): M {
return { ...msg, timestamp: ts };
}
describe("extractTopology", () => {
it("returns only the root (with the Task's running state) when nothing is spawned", () => {
const m = createStreamModel();
pushMessage(m, userText("hello"));
const nodes = extractTopology(m, "root", true);
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
sessionId: "root",
depth: 0,
origin: [],
parentId: null,
running: true,
agentId: null,
});
expect(extractTopology(m, "root", false)[0]!.running).toBe(false);
});
it("collects a bound spawn with agent_id from the call arguments; running follows the card's output state", () => {
const m = createStreamModel();
pushMessage(m, userText("go"));
spawnChild(m, "t1", "child1", '{"prompt": "count", "agent_id": "researcher"}');
let nodes = extractTopology(m, "root", true);
expect(nodes).toHaveLength(2);
expect(nodes[1]).toMatchObject({
sessionId: "child1",
agentId: "researcher",
running: true,
depth: 1,
origin: ["child1"],
parentId: "root",
});
pushMessage(m, toolCallOutput({ output: "done", toolCallId: "t1" }));
nodes = extractTopology(m, "root", false);
expect(nodes[1]!.running).toBe(false);
});
it("prefers the child's own session_meta capture (agent_state path) over the call-argument agent id", () => {
const m = createStreamModel();
pushMessage(m, userText("go"));
pushMessage(
m,
toolCall({ name: "run_subagent", arguments: '{"agent_id": "arg_agent"}', toolCallId: "t1" }),
);
pushMessage(m, approvalDecision("allow", "t1"));
pushMessage(
m,
withOrigin(meta("child1", "/data/proj/agents/meta_agent/agent_state"), "child1"),
);
const nodes = extractTopology(m, "root", true);
expect(nodes[1]!.agentId).toBe("meta_agent");
});
it("recurses into a child's own items for deeper spawns (bound grandchild: depth 2, full origin chain)", () => {
const m = createStreamModel();
pushMessage(m, userText("go"));
spawnChild(m, "t1", "child1");
// The child spawns its own subagent: run_subagent card + allow + first grandchild message, all inside child1.
pushMessage(
m,
withOrigin(toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "s1" }), "child1"),
);
pushMessage(m, withOrigin(approvalDecision("allow", "s1"), "child1"));
pushMessage(m, withOrigin(withOrigin(assistantText("gc reply"), "gc1"), "child1"));
const nodes = extractTopology(m, "root", true);
expect(nodes.map((n) => n.sessionId)).toEqual(["root", "child1", "gc1"]);
expect(nodes[2]).toMatchObject({
depth: 2,
origin: ["child1", "gc1"],
parentId: "child1",
running: true,
});
});
it("collects a standalone SubagentItem (no bindable card) as a done node and dedupes a second reference to the same child", () => {
const m = createStreamModel();
pushMessage(m, userText("go"));
// Denied card never binds -> the child lands as a standalone item.
pushMessage(m, toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "t1" }));
pushMessage(m, approvalDecision("deny", "t1"));
pushMessage(m, withOrigin(meta("childX"), "childX"));
const standalone = m.items.find((i) => i.kind === "subagent") as SubagentItem;
// A repoll referencing the same child again (defensive: extraction must not duplicate the node).
m.items.push({ kind: "subagent", id: 999, sessionId: "childX", model: standalone.model });
const nodes = extractTopology(m, "root", true);
expect(nodes.filter((n) => n.sessionId === "childX")).toHaveLength(1);
expect(nodes[1]).toMatchObject({ sessionId: "childX", running: false, parentId: "root" });
});
it("shows only the latest Task's spawns: a new user message resets the graph to the root", () => {
const m = createStreamModel();
pushMessage(m, userText("task 1"));
spawnChild(m, "t1", "child1");
pushMessage(m, toolCallOutput({ output: "done", toolCallId: "t1" }));
pushMessage(m, userText("task 2"));
expect(extractTopology(m, "root", true).map((n) => n.sessionId)).toEqual(["root"]);
spawnChild(m, "t2", "child2");
expect(extractTopology(m, "root", true).map((n) => n.sessionId)).toEqual(["root", "child2"]);
});
});
describe("extractTopologyForChild (historical Task slice)", () => {
/** Task 1 spawns child1 (finished), then Task 2 spawns child2 (still running). */
const twoTasks = (): StreamModel => {
const m = createStreamModel();
pushMessage(m, userText("task 1"));
spawnChild(m, "t1", "child1");
pushMessage(m, toolCallOutput({ output: "done", toolCallId: "t1" }));
pushMessage(m, userText("task 2"));
spawnChild(m, "t2", "child2");
return m;
};
it("a chip from an older Task yields THAT Task's tree while the default extraction stays on the latest", () => {
const m = twoTasks();
expect(extractTopology(m, "root", true).map((n) => n.sessionId)).toEqual(["root", "child2"]);
const historical = extractTopologyForChild(m, "root", true, "child1")!;
expect(historical.map((n) => n.sessionId)).toEqual(["root", "child1"]);
// An older Task has ended by definition (the next user message closed it): its root reads
// done even while the LATEST Task is running.
expect(historical[0]!.running).toBe(false);
expect(historical[1]!.running).toBe(false);
});
it("anchoring on a latest-Task child returns the live latest slice (root keeps the running state)", () => {
const latest = extractTopologyForChild(twoTasks(), "root", true, "child2")!;
expect(latest.map((n) => n.sessionId)).toEqual(["root", "child2"]);
expect(latest[0]!.running).toBe(true);
expect(latest[1]!.running).toBe(true);
});
it("an unreferenced anchor gives null — the caller falls back to the latest Task", () => {
expect(extractTopologyForChild(twoTasks(), "root", true, "ghost")).toBeNull();
});
it("anchors on a standalone SubagentItem; a mid-stream join with no leading user item slices from the start, bounded by the next Task", () => {
const m = createStreamModel();
// Mid-stream join: the child appears before any user item (no bindable card either).
pushMessage(m, withOrigin(meta("childX"), "childX"));
pushMessage(m, userText("next task"));
spawnChild(m, "t9", "childY");
const nodes = extractTopologyForChild(m, "root", true, "childX")!;
// The slice runs from the stream start to the next user item: childY never leaks in.
expect(nodes.map((n) => n.sessionId)).toEqual(["root", "childX"]);
expect(nodes[0]!.running).toBe(false);
});
});
describe("latestTaskHasSubagent (auto-open trigger)", () => {
it("true only while the LATEST Task references a spawn; a plain follow-up Task resets it", () => {
const m = createStreamModel();
pushMessage(m, userText("task 1"));
expect(latestTaskHasSubagent(m)).toBe(false);
spawnChild(m, "t1", "child1");
expect(latestTaskHasSubagent(m)).toBe(true);
// A new Task without spawns: the earlier child must not keep the trigger armed.
pushMessage(m, userText("task 2, no spawns"));
expect(latestTaskHasSubagent(m)).toBe(false);
});
it("counts a standalone SubagentItem (unbindable spawn) too", () => {
const m = createStreamModel();
pushMessage(m, userText("go"));
pushMessage(m, withOrigin(meta("childX"), "childX")); // no bindable card -> standalone item
expect(latestTaskHasSubagent(m)).toBe(true);
});
});
describe("per-node elapsed time (wall clock of the spawn, never a sum of items)", () => {
const T0 = Date.parse("2026-07-25T10:00:00.000Z");
const iso = (offsetMs: number): string => new Date(T0 + offsetMs).toISOString();
it("live: a running child ticks from its first message timestamp; done freezes at its last activity", () => {
const m = createStreamModel();
pushMessage(m, at(userText("go"), iso(0)), 1_000);
pushMessage(
m,
at(toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "t1" }), iso(1_000)),
2_000,
);
pushMessage(m, at(approvalDecision("allow", "t1"), iso(1_500)), 2_500);
pushMessage(m, at(withOrigin(meta("child1"), "child1"), iso(2_000)), 3_000);
let node = extractTopology(m, "root", true)[1]!;
expect(node.running).toBe(true);
expect(node.startedMs).toBe(T0 + 2_000);
pushMessage(m, at(withOrigin(assistantText("report"), "child1"), iso(9_000)), 10_000);
pushMessage(m, at(toolCallOutput({ output: "done", toolCallId: "t1" }), iso(9_500)), 10_500);
node = extractTopology(m, "root", false)[1]!;
expect(node.running).toBe(false);
expect(node.elapsedMs).toBe(7_000); // child first appeared at +2s, last acted at +9s
// The live wall-clock stamps recorded the same lifecycle in local time.
const sub = m.subagents.get("child1")!;
expect(sub.firstSeenLocalMs).toBe(3_000);
expect(sub.lastActivityLocalMs).toBe(10_000);
});
it("history replay: local stamps collapse to the load instant, so the span comes from message timestamps", () => {
// One synchronous replay: every push shares the same local `now` (pushMessages evaluates it once).
const LOAD = 5_000_000;
const m = createStreamModel();
pushMessage(m, at(userText("go"), iso(0)), LOAD);
pushMessage(
m,
at(toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "t1" }), iso(1_000)),
LOAD,
);
pushMessage(m, at(approvalDecision("allow", "t1"), iso(1_500)), LOAD);
pushMessage(m, at(withOrigin(meta("child1"), "child1"), iso(2_000)), LOAD);
pushMessage(m, at(withOrigin(assistantText("report"), "child1"), iso(60_000)), LOAD);
pushMessage(m, at(toolCallOutput({ output: "done", toolCallId: "t1" }), iso(61_000)), LOAD);
// The trap this design avoids: after a replay the local pair spans ~0 (load-relative).
const sub = m.subagents.get("child1")!;
expect(sub.lastActivityLocalMs! - sub.firstSeenLocalMs!).toBe(0);
const node = extractTopology(m, "root", false)[1]!;
expect(node.elapsedMs).toBe(58_000); // +2s → +60s in message time, reproduced after reload
expect(node.startedMs).toBe(T0 + 2_000);
});
it("unparseable timestamps fall back to the local wall-clock pair; the root never carries a duration", () => {
const m = createStreamModel();
pushMessage(m, at(userText("go"), "not-a-date"), 1_000);
pushMessage(
m,
at(toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "t1" }), "not-a-date"),
1_100,
);
pushMessage(m, at(approvalDecision("allow", "t1"), "not-a-date"), 1_200);
pushMessage(m, at(withOrigin(meta("child1"), "child1"), "not-a-date"), 2_000);
pushMessage(m, at(withOrigin(assistantText("r"), "child1"), "not-a-date"), 6_000);
const nodes = extractTopology(m, "root", true);
expect(nodes[0]!.startedMs).toBeUndefined();
expect(nodes[0]!.elapsedMs).toBeUndefined();
expect(nodes[1]!.startedMs).toBe(2_000); // firstSeenLocalMs fallback
expect(nodes[1]!.elapsedMs).toBe(4_000); // local pair span
});
});
describe("identity helpers", () => {
it("latestTaskStart finds the last user_text/user_image and falls back to 0 (user items only — steering never starts a Task)", () => {
const m = createStreamModel();
pushMessage(m, assistantText("orphan"));
expect(latestTaskStart(m.items)).toBe(0);
pushMessage(m, userText("t1"));
expect(latestTaskStart(m.items)).toBe(1);
});
it("taskStartCount counts Task-starting user items (text and image); steering chips never count", () => {
const m = createStreamModel();
expect(taskStartCount(m.items)).toBe(0);
pushMessage(m, userText("t1"));
pushMessage(m, assistantText("r1"));
// Mid-run steering renders as a user_steering item — inside the running Task, not a new one.
pushMessage(m, userText("[user_steering]\nnudge\n[/user_steering]"));
expect(taskStartCount(m.items)).toBe(1);
pushMessage(m, imageUrlMessage("data:image/png;base64,xx"));
expect(taskStartCount(m.items)).toBe(2);
pushMessage(m, userText("t3"));
expect(taskStartCount(m.items)).toBe(3);
});
it("agentIdFromStatePath takes the parent directory name; degenerate paths give null", () => {
expect(agentIdFromStatePath("/data/proj/agents/researcher/agent_state")).toBe("researcher");
expect(agentIdFromStatePath("C:\\data\\proj\\agents\\win_agent\\agent_state")).toBe(
"win_agent",
);
expect(agentIdFromStatePath("agent_state")).toBeNull();
expect(agentIdFromStatePath("")).toBeNull();
});
it("agentIdFromRunSubagentArgs reads agent_id from complete JSON only", () => {
expect(agentIdFromRunSubagentArgs('{"prompt": "x", "agent_id": "helper"}')).toBe("helper");
expect(agentIdFromRunSubagentArgs('{"prompt": "x"}')).toBeNull();
expect(agentIdFromRunSubagentArgs('{"agent_id": "hel')).toBeNull();
expect(agentIdFromRunSubagentArgs("")).toBeNull();
});
it("session_meta capture lands on the nested model (agent/provider/model/source), never on the main model", () => {
const m = createStreamModel();
pushMessage(m, meta("main", "/data/proj/agents/main_agent/agent_state"));
expect(m.meta).toBeNull();
pushMessage(m, withOrigin(meta("c1", "/data/proj/agents/child_agent/agent_state"), "c1"));
expect(m.subagents.get("c1")!.meta).toEqual({
agentId: "child_agent",
provider: "custom",
modelId: "m",
source: "subagent",
});
});
it("modelAtOrigin walks the nested chain; a missing hop or an empty chain gives null", () => {
const m = createStreamModel();
pushMessage(m, withOrigin(withOrigin(assistantText("deep"), "gc1"), "c1"));
expect(modelAtOrigin(m, ["c1"])).toBe(m.subagents.get("c1"));
expect(modelAtOrigin(m, ["c1", "gc1"])).toBe(m.subagents.get("c1")!.subagents.get("gc1"));
expect(modelAtOrigin(m, ["c1", "nope"])).toBeNull();
expect(modelAtOrigin(m, [])).toBeNull();
});
it("hasPendingWithinOrigin matches keys at or below the chain, never the main session's own keys", () => {
const keys = [
approvalKey([], "t-main"),
approvalKey(["c1"], "t-child"),
approvalKey(["c1", "gc1"], "t-deep"),
];
expect(hasPendingWithinOrigin(keys, ["c1"])).toBe(true);
expect(hasPendingWithinOrigin(keys, ["c1", "gc1"])).toBe(true);
expect(hasPendingWithinOrigin(keys, ["c2"])).toBe(false);
expect(hasPendingWithinOrigin([approvalKey([], "t-main")], ["c1"])).toBe(false);
});
it("resolveAgentLabel: agents list name -> bare agent id -> session row's agent -> session title -> null", () => {
const agents = [
{ agentId: "researcher", name: "Researcher" },
{ agentId: "plain" },
{ agentId: "unnamed", name: "" },
];
const sessions = [
{ sessionId: "s1", agentId: "researcher", title: "Row title" },
{ sessionId: "s2", agentId: "ghost" },
];
expect(resolveAgentLabel({ sessionId: "x", agentId: "researcher" }, agents, sessions)).toBe(
"Researcher",
);
expect(resolveAgentLabel({ sessionId: "x", agentId: "plain" }, agents, sessions)).toBe("plain");
// An empty display name counts as missing, not as a real (blank) label.
expect(resolveAgentLabel({ sessionId: "x", agentId: "unnamed" }, agents, sessions)).toBe(
"unnamed",
);
// Agent unknown to the list: the id itself still beats nothing.
expect(resolveAgentLabel({ sessionId: "x", agentId: "mystery" }, agents, sessions)).toBe(
"mystery",
);
// No agent id on the node: the session row supplies it.
expect(resolveAgentLabel({ sessionId: "s1", agentId: null }, agents, sessions)).toBe(
"Researcher",
);
expect(resolveAgentLabel({ sessionId: "s2", agentId: null }, agents, sessions)).toBe("ghost");
expect(resolveAgentLabel({ sessionId: "gone", agentId: null }, agents, sessions)).toBeNull();
});
it("shortSessionId keeps the distinctive tail", () => {
expect(shortSessionId("session-2026-07-14-09-05-11-1a2b3c01")).toBe("1a2b3c01");
expect(shortSessionId("short")).toBe("short");
});
});
describe("layoutTopology", () => {
const tree = () => {
const m = createStreamModel();
pushMessage(m, userText("go"));
spawnChild(m, "t1", "child1");
pushMessage(
m,
withOrigin(toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "s1" }), "child1"),
);
pushMessage(m, withOrigin(approvalDecision("allow", "s1"), "child1"));
pushMessage(m, withOrigin(withOrigin(assistantText("gc"), "gc1"), "child1"));
spawnChild(m, "t2", "child2");
return extractTopology(m, "root", true); // root -> child1 -> gc1, root -> child2
};
it("is deterministic (same tree, same layout)", () => {
// Frozen clock: the fixture's node stamps (startedMs/elapsedMs) come from the wall clock and
// message timestamps, so two separately BUILT trees only match with time stopped — the
// layout itself is a pure function of the nodes.
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-07-25T10:00:00.000Z"));
expect(layoutTopology(tree())).toEqual(layoutTopology(tree()));
} finally {
vi.useRealTimers();
}
});
it("columns follow depth, leaves take successive rows, and a parent centers on its children", () => {
const layout = layoutTopology(tree());
const at = (id: string) => layout.nodes.find((p) => p.node.sessionId === id)!;
const colX = (depth: number) => PAD + depth * (NODE_W + GAP_X);
expect(at("root").x).toBe(colX(0));
expect(at("child1").x).toBe(colX(1));
expect(at("child2").x).toBe(colX(1));
expect(at("gc1").x).toBe(colX(2));
// Leaves in DFS order: gc1 row 0, child2 row 1; child1 centers on its only child; root centers between its two children.
expect(at("gc1").y).toBe(PAD);
expect(at("child2").y).toBe(PAD + (NODE_H + GAP_Y));
expect(at("child1").y).toBe(at("gc1").y);
expect(at("root").y).toBe((at("child1").y + at("child2").y) / 2);
// Canvas hugs the tree: 3 columns wide, 2 leaf rows tall.
expect(layout.width).toBe(PAD * 2 + 3 * NODE_W + 2 * GAP_X);
expect(layout.height).toBe(PAD * 2 + 2 * NODE_H + GAP_Y);
});
it("draws a straight edge between aligned nodes and an orthogonal elbow otherwise", () => {
const layout = layoutTopology(tree());
const edge = (to: string) => layout.edges.find((e) => e.toId === to)!;
expect(layout.edges).toHaveLength(3);
// child1 -> gc1 share a midline: single horizontal segment.
expect(edge("gc1").path).toMatch(/^M \S+ \S+ H \S+$/);
// root -> child2 offset rows: H mid, V to the child's midline, H into the box.
expect(edge("child2").path).toMatch(/^M \S+ \S+ H \S+ V \S+ H \S+$/);
expect(edge("child2").fromId).toBe("root");
});
it("lays out a lone root without edges", () => {
const m = createStreamModel();
pushMessage(m, userText("solo"));
const layout = layoutTopology(extractTopology(m, "root", false));
expect(layout.nodes).toHaveLength(1);
expect(layout.edges).toHaveLength(0);
expect(layout.height).toBe(PAD * 2 + NODE_H);
});
});
@@ -0,0 +1,69 @@
/**
* advancePanelTaskScope unit tests: the subagents panel's TASK-SCOPED visibility rules —
* entering a session and every new Task close the panel by default; the current task's first
* live spawn auto-opens it (one attempt per task, so a manual close afterwards is respected
* until the next boundary); a taskCount decrease is a defensive re-baseline, never a boundary.
* The chat page feeds observations per render (session id + taskStartCount + live-spawn flag)
* and applies the returned action under its own layout guards.
*/
import { describe, expect, it } from "vitest";
import {
advancePanelTaskScope,
createPanelTaskScope,
} from "../src/features/chat/use-subagents-panel";
const obs = (sessionId: string | null, taskCount: number, liveSpawn = false) => ({
sessionId,
taskCount,
liveSpawn,
});
describe("advancePanelTaskScope (task-scoped panel visibility)", () => {
it("entering a session closes by default; a mid-run entry with a live spawn auto-opens instead", () => {
const s = createPanelTaskScope();
expect(advancePanelTaskScope(s, obs("A", 3))).toBe("close");
const mid = createPanelTaskScope();
expect(advancePanelTaskScope(mid, obs("A", 3, true))).toBe("autoOpen");
});
it("a new Task closes the panel and RE-ARMS the auto-open; the task's own spawn then opens it once", () => {
const s = createPanelTaskScope();
advancePanelTaskScope(s, obs("A", 1)); // session entry
expect(advancePanelTaskScope(s, obs("A", 1, true))).toBe("autoOpen"); // task 1 spawns
expect(advancePanelTaskScope(s, obs("A", 1, true))).toBeNull(); // once per task
expect(advancePanelTaskScope(s, obs("A", 2))).toBe("close"); // task 2 boundary
expect(advancePanelTaskScope(s, obs("A", 2))).toBeNull(); // the boundary fires once
expect(advancePanelTaskScope(s, obs("A", 2, true))).toBe("autoOpen"); // re-armed for task 2
// Consumed again: a manual close mid-task stays respected until the next boundary.
expect(advancePanelTaskScope(s, obs("A", 2, true))).toBeNull();
});
it("a boundary arriving together with the new task's spawn opens rather than closing (batched commit)", () => {
const s = createPanelTaskScope();
advancePanelTaskScope(s, obs("A", 1));
expect(advancePanelTaskScope(s, obs("A", 2, true))).toBe("autoOpen");
});
it("a session switch resets the per-task guard", () => {
const s = createPanelTaskScope();
expect(advancePanelTaskScope(s, obs("A", 1, true))).toBe("autoOpen"); // consumed for A's task 1
expect(advancePanelTaskScope(s, obs("B", 1))).toBe("close"); // B is entered closed
expect(advancePanelTaskScope(s, obs("B", 1, true))).toBe("autoOpen"); // B's task 1 arms fresh
});
it("steady observations (steering, more output within the same task) do nothing", () => {
const s = createPanelTaskScope();
advancePanelTaskScope(s, obs("A", 2));
expect(advancePanelTaskScope(s, obs("A", 2))).toBeNull();
expect(advancePanelTaskScope(s, obs("A", 2))).toBeNull();
});
it("a taskCount decrease re-baselines silently: no boundary, no surprise reopen; the next real boundary works", () => {
const s = createPanelTaskScope();
advancePanelTaskScope(s, obs("A", 5));
// A resync swapped in a smaller model while a spawn runs: neither close nor auto-open —
// reopening a panel the user closed mid-task would be a surprise.
expect(advancePanelTaskScope(s, obs("A", 3, true))).toBeNull();
expect(advancePanelTaskScope(s, obs("A", 4))).toBe("close");
});
});