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