From 08f59baa70bcfc1aea83924f0f7081c2d14f4785 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Tue, 28 Jul 2026 00:17:18 +0800 Subject: [PATCH] feat(web): subagent side panel with a live call-graph, compact chips in the stream (#78) Co-authored-by: Claude Fable 5 --- packages/core/src/agent.ts | 14 +- packages/core/test/agent.test.ts | 41 ++ packages/docs/content/web-app.en.md | 2 +- packages/docs/content/web-app.zh.md | 2 +- packages/web/e2e/mock-llm.mjs | 44 +- packages/web/e2e/subagent.spec.mjs | 349 ++++++++++--- .../src/features/chat/agent-topology-view.tsx | 97 ++++ .../web/src/features/chat/agent-topology.ts | 370 +++++++++++++ packages/web/src/features/chat/chat-page.tsx | 138 ++++- .../web/src/features/chat/message-item.tsx | 6 +- .../web/src/features/chat/message-stream.tsx | 2 + .../web/src/features/chat/subagent-card.tsx | 71 --- .../web/src/features/chat/subagent-chip.tsx | 72 +++ .../web/src/features/chat/subagents-panel.tsx | 108 ++++ .../web/src/features/chat/subagents-view.tsx | 202 +++++++ .../web/src/features/chat/tool-call-card.tsx | 34 +- .../src/features/chat/use-subagents-panel.ts | 254 +++++++++ packages/web/src/lib/omni/stream-model.ts | 117 ++++- packages/web/src/lib/strings-en.ts | 11 + packages/web/src/lib/strings.ts | 11 + packages/web/test/agent-topology.test.ts | 491 ++++++++++++++++++ packages/web/test/panel-task-scope.test.ts | 69 +++ 22 files changed, 2330 insertions(+), 175 deletions(-) create mode 100644 packages/web/src/features/chat/agent-topology-view.tsx create mode 100644 packages/web/src/features/chat/agent-topology.ts delete mode 100644 packages/web/src/features/chat/subagent-card.tsx create mode 100644 packages/web/src/features/chat/subagent-chip.tsx create mode 100644 packages/web/src/features/chat/subagents-panel.tsx create mode 100644 packages/web/src/features/chat/subagents-view.tsx create mode 100644 packages/web/src/features/chat/use-subagents-panel.ts create mode 100644 packages/web/test/agent-topology.test.ts create mode 100644 packages/web/test/panel-task-scope.test.ts diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 93c4209..c9ce393 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -605,7 +605,19 @@ export class Agent { const childApprove = approve ? (tc: OmniMessage) => approve(withOrigin(tc, hop)) : undefined; - for await (const msg of childSession.run([userText(prompt)], { + // The engine writes a run's input to the CHILD's own Trace but never replays + // it to its consumer (a session's normal caller typed that input itself) — + // here the consumer is the PARENT, whose frontend has never seen the child's + // prompt. Forward the input message itself (origin-tagged, ahead of the run's + // output) so the live nested view shows the child's user side exactly like a + // reloaded one (history expansion splices the child Trace, which carries this + // same message). The parent engine drops origin messages from the parent + // Trace, so replay never duplicates it. Later rounds (input_subagent + // follow-up prompts) come through this same generator and are forwarded the + // same way. + const input = userText(prompt); + yield withOrigin(input, hop); + for await (const msg of childSession.run([input], { ...(signal ? { signal } : {}), ...(childApprove ? { approve: childApprove } : {}), })) { diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 8456d2f..8b13c48 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -473,6 +473,47 @@ describe("run_subagent spawning follows the PARENT session (never the Project de parent.dispose(); } }); + + it("forwards the run input as an origin-tagged user message, after session_meta and before any model output", async () => { + const agent = await createAgent(); + const ws = path.join(tmpRoot, "ws-input-forward"); + await fs.mkdir(ws, { recursive: true }); + const parent = await agent.createSession({ + workspaceDir: ws, + modelId: "claude-sonnet-4-6", + provider: "anthropic", + }); + const runner = lastSpawnedRunner(); + const handle = await runner.spawn({}); + try { + // The child's user side has no other live source: a session's normal caller typed its + // own input, but this caller is the PARENT — the frontend renders the child conversation + // purely from these forwarded messages. (Regression: the panel showed no user messages + // live, while a reload — child-Trace expansion — did show them.) + const gen = handle.run({ prompt: "count the TODO items" }); + const first = (await gen.next()).value as { type: string }; + expect(first.type).toBe("session_meta"); + // The input yield precedes childSession.run: pulling it never issues an LLM request. + const second = await gen.next(); + expect(second.done).toBe(false); + const msg = second.value as { + type: string; + origin?: string[]; + payload: { type: string; role?: string; text?: string }; + }; + expect(msg.origin).toEqual([handle.sessionId]); + expect(msg.type).toBe("model_msg"); + expect(msg.payload).toMatchObject({ + type: "text", + role: "user", + text: "count the TODO items", + }); + await gen.return(undefined); + } finally { + handle.dispose(); + parent.dispose(); + } + }); }); describe("Agent.createSession max output tokens (per-model cap wins over the Agent config)", () => { diff --git a/packages/docs/content/web-app.en.md b/packages/docs/content/web-app.en.md index 73aeb0d..f9a5277 100644 --- a/packages/docs/content/web-app.en.md +++ b/packages/docs/content/web-app.en.md @@ -41,7 +41,7 @@ There are four approval modes: `allow-all`, `deny-all`, `read-only` (only read-o - Model text renders token by token; thinking blocks are collapsible; - Tool cards expand to show arguments and output, with a live timer while running; -- Subagents appear as nested cards; context compaction shows a banner; +- Subagents leave a full-width row in the stream, styled like the other collapsed step rows (agent avatar, name, short session id, running spinner, and an amber dot while one of their tool calls awaits approval); clicking it opens the agents side panel — a call graph of that row's Task on top (each node shows its elapsed time; click a node to switch) and the selected child's live conversation below — the child's own user prompts included — where nested tool cards and approvals work exactly as in the main chat. Panel visibility is task-scoped: sending a message that starts a new task closes it by default (entering a session starts closed too), and it comes back when you open it yourself or — on desktop — when the current task spawns a subagent (one auto-open per task; a manual close holds until the next task; never over an open files panel). Opening the panel from the toolbar, or switching sessions, shows the latest Task's graph; a row on an older turn brings back that turn's graph. The agents panel and the Workspace files panel never show together — opening one closes the other. Context compaction shows a banner; - After each Task, a stats line shows tokens, TPS, elapsed time, and cost. ### Input and Shortcuts diff --git a/packages/docs/content/web-app.zh.md b/packages/docs/content/web-app.zh.md index 27ba486..0a654e5 100644 --- a/packages/docs/content/web-app.zh.md +++ b/packages/docs/content/web-app.zh.md @@ -41,7 +41,7 @@ penguin web - 模型文本逐 Token 渲染,思考块可折叠; - 工具卡片可展开查看参数与输出,执行中显示实时计时; -- 子 Agent 以嵌套卡片呈现;上下文压缩以横幅提示; +- 子 Agent 在消息流中只留下一条通栏行,样式与其他折叠步骤行一致(头像、名称、短会话号、运行中转圈,子会话内有待审批时带琥珀色圆点);点击打开右侧智能体面板——上方是该行所属 Task 派生关系的调用图(节点上显示运行时长,点节点切换),下方是所选子会话的实时对话(含子会话自己的用户消息),嵌套工具卡片与审批与主对话完全一致;面板的显示以任务为界:发送消息开启新任务时默认关闭(进入会话时同样默认关闭),仅在手动打开、或(桌面端)当前任务派生子智能体时重新显示(每个任务自动打开一次,任务内手动关闭后保持关闭到下个任务,且不抢占已打开的工作区面板);从工具条打开面板或切换 Session 时默认展示最新 Task 的调用图,点旧轮次的行则回看那一轮的调用图;智能体面板与工作区面板不会同时显示,打开一个即关闭另一个;上下文压缩以横幅提示; - 每个 Task 结束后显示统计行:Token 用量、TPS、耗时与费用。 ### 输入与快捷操作 diff --git a/packages/web/e2e/mock-llm.mjs b/packages/web/e2e/mock-llm.mjs index 5f5fc8e..4719473 100644 --- a/packages/web/e2e/mock-llm.mjs +++ b/packages/web/e2e/mock-llm.mjs @@ -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" }, ]); diff --git a/packages/web/e2e/subagent.spec.mjs b/packages/web/e2e/subagent.spec.mjs index c2134d8..ae1c916 100644 --- a/packages/web/e2e/subagent.spec.mjs +++ b/packages/web/e2e/subagent.spec.mjs @@ -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(); +}); diff --git a/packages/web/src/features/chat/agent-topology-view.tsx b/packages/web/src/features/chat/agent-topology-view.tsx new file mode 100644 index 0000000..50f12bc --- /dev/null +++ b/packages/web/src/features/chat/agent-topology-view.tsx @@ -0,0 +1,97 @@ +/** + * Call-graph rendering for the subagents panel: hand-rolled SVG for the edges plus absolutely + * positioned real + ); + })} + + + ); +} diff --git a/packages/web/src/features/chat/agent-topology.ts b/packages/web/src/features/chat/agent-topology.ts new file mode 100644 index 0000000..ce37b9c --- /dev/null +++ b/packages/web/src/features/chat/agent-topology.ts @@ -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)["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([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(); + 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(); + 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; +} diff --git a/packages/web/src/features/chat/chat-page.tsx b/packages/web/src/features/chat/chat-page.tsx index 0cd02f3..689f66f 100644 --- a/packages/web/src/features/chat/chat-page.tsx +++ b/packages/web/src/features/chat/chat-page.tsx @@ -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 (
@@ -862,7 +944,41 @@ export function ChatPage() {
- {/* 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). */} + + + {/* 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). */} - {open && ( -
- -
- )} - - ); -} diff --git a/packages/web/src/features/chat/subagent-chip.tsx b/packages/web/src/features/chat/subagent-chip.tsx new file mode 100644 index 0000000..dab56eb --- /dev/null +++ b/packages/web/src/features/chat/subagent-chip.tsx @@ -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. + + ); +} diff --git a/packages/web/src/features/chat/subagents-panel.tsx b/packages/web/src/features/chat/subagents-panel.tsx new file mode 100644 index 0000000..fc490dd --- /dev/null +++ b/packages/web/src/features/chat/subagents-panel.tsx @@ -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 ( + + + + ); +} + +export function SubagentsPanel({ + session, + panel, + model, + version, + taskRunning, + ctx, +}: { + session: SessionInfo; + panel: SubagentsPanelState; + model: StreamModel; + version: number; + taskRunning: boolean; + ctx: StreamRenderContext; +}) { + const view = ( + + ); + + if (!panel.isDocked) { + return ( + panel.setOpen(false)} + title={S.subagentPanel.title} + > +
+
{view}
+
+
+ ); + } + + return ( + <> + {panel.open && ( +
+ )} +
+
+
+

+ {S.subagentPanel.title} +

+ +
+
{view}
+
+
+ + ); +} diff --git a/packages/web/src/features/chat/subagents-view.tsx b/packages/web/src/features/chat/subagents-view.tsx new file mode 100644 index 0000000..9a14488 --- /dev/null +++ b/packages/web/src/features/chat/subagents-view.tsx @@ -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(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 ( +
+ {/* Call graph of the displayed Task — latest by default, a chip's Task when pinned (capped height; scrolls both ways for deep/wide trees). */} +
+

+ {S.subagentPanel.topologyLabel} +

+ {nodes.length > 1 ? ( +
+ setSelected({ sessionId: node.sessionId, origin: node.origin })} + /> +
+ ) : ( +

{S.subagentPanel.empty}

+ )} +
+ + {/* Selected conversation (or the root note / empty state). */} + {active === null ? ( +
+ +
+ ) : isRoot ? ( +
+

+ {S.subagentPanel.mainSessionNote} +

+
+ ) : activeModel === null ? ( + // The chain broke (e.g. a resync swapped in a fresh model and this child hasn't re-streamed yet). +
+ +
+ ) : ( + <> + {/* Slim identity strip for the conversation below. */} +
+ + + {activeLabel} + + + {shortSessionId(active.sessionId)} + + {activeRunning && ( + + )} +
+
+ {/* Keyed by child: switching nodes resets scroll-follow instead of carrying the old position over. */} + {childCtx && ( + + )} +
+ + )} +
+ ); +} diff --git a/packages/web/src/features/chat/tool-call-card.tsx b/packages/web/src/features/chat/tool-call-card.tsx index b881e58..7db38b6 100644 --- a/packages/web/src/features/chat/tool-call-card.tsx +++ b/packages/web/src/features/chat/tool-call-card.tsx @@ -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
)} - {/* 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 && (
-
diff --git a/packages/web/src/features/chat/use-subagents-panel.ts b/packages/web/src/features/chat/use-subagents-panel.ts new file mode 100644 index 0000000..5854319 --- /dev/null +++ b/packages/web/src/features/chat/use-subagents-panel.ts @@ -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) => 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; +} + +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("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(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) => { + 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, + }; +} diff --git a/packages/web/src/lib/omni/stream-model.ts b/packages/web/src/lib/omni/stream-model.ts index e9b732d..14e7d1c 100644 --- a/packages/web/src/lib/omni/stream-model.ts +++ b/packages/web/src/lib/omni/stream-model.ts @@ -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): 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 + * `//agents//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, + 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--) { diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index 1eb33ee..2bfdc8c 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -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, }, + /** 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", diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index a8b5ff5..72ea36a 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -697,6 +697,7 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题( `${n} 个文件`, openPreview: "点击预览", @@ -778,6 +779,16 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(, }, + /** Subagents side panel: call-graph of the latest Task + the selected child conversation. */ + subagentPanel: { + title: "智能体面板", + topologyLabel: "调用关系", + mainSessionNote: "主会话请在对话区查看", + empty: "本次任务尚未派生子智能体", + nodeRunning: "运行中", + nodeDone: "已完成", + }, + files: { title: "文件", upload: "上传", diff --git a/packages/web/test/agent-topology.test.ts b/packages/web/test/agent-topology.test.ts new file mode 100644 index 0000000..d01c370 --- /dev/null +++ b/packages/web/test/agent-topology.test.ts @@ -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 { + 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(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); + }); +}); diff --git a/packages/web/test/panel-task-scope.test.ts b/packages/web/test/panel-task-scope.test.ts new file mode 100644 index 0000000..cf905e9 --- /dev/null +++ b/packages/web/test/panel-task-scope.test.ts @@ -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"); + }); +});