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

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

No files matched your search

@@ -0,0 +1,97 @@
/**
* Call-graph rendering for the subagents panel: hand-rolled SVG for the edges plus absolutely
* positioned real <button> nodes on top (real buttons keep keyboard focus, aria naming, and CSS
* text truncation for free — an all-SVG node would have to reinvent each). Geometry comes from
* the pure layoutTopology (agent-topology.ts); the canvas renders at its natural pixel size and
* the wrapper scrolls horizontally when the tree is deeper than the panel is wide.
* Each child node shows its wall-clock elapsed time (ticking while it runs, frozen at its last
* activity when done — never a sum of its items); the root carries no stamps and shows none.
*/
import { S } from "../../lib/strings";
import { humanizeDuration } from "../../lib/format";
import { AgentAvatar } from "../../components/ui/agent-avatar";
import { StatusIcon } from "../../components/ui/status-icon";
import { LiveDuration } from "./live-duration";
import { layoutTopology, NODE_H, NODE_W } from "./agent-topology";
import type { TopologyNode } from "./agent-topology";
export function AgentTopologyView({
nodes,
selectedId,
labelFor,
onSelect,
}: {
nodes: TopologyNode[];
/** Session id of the highlighted node (null — e.g. a focused child from an older Task — highlights nothing). */
selectedId: string | null;
/** Resolved display label for a node (the view owns name resolution; the graph only renders it). */
labelFor: (node: TopologyNode) => string;
onSelect: (node: TopologyNode) => void;
}) {
const layout = layoutTopology(nodes);
return (
<div role="group" aria-label={S.subagentPanel.topologyLabel} className="overflow-x-auto">
<div className="relative" style={{ width: layout.width, height: layout.height }}>
{/* Edges only — nodes are HTML buttons layered above. */}
<svg
width={layout.width}
height={layout.height}
className="absolute inset-0 text-gray-300 dark:text-gray-700"
aria-hidden
>
{layout.edges.map((edge) => (
<path
key={`${edge.fromId}>${edge.toId}`}
d={edge.path}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
))}
</svg>
{layout.nodes.map(({ node, x, y }) => {
const label = labelFor(node);
const selected = node.sessionId === selectedId;
const stateLabel = node.running ? S.subagentPanel.nodeRunning : S.subagentPanel.nodeDone;
return (
<button
key={node.sessionId}
type="button"
aria-label={`${label} · ${stateLabel}`}
aria-pressed={selected}
title={`${label} · ${stateLabel}`}
onClick={() => onSelect(node)}
style={{ left: x, top: y, width: NODE_W, height: NODE_H }}
className={`absolute flex items-center gap-1.5 rounded-md border bg-white px-2 text-left transition-colors duration-150 dark:bg-gray-900 ${
selected
? "border-brand-500 ring-1 ring-brand-500"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800/60"
}`}
>
<AgentAvatar id={node.agentId ?? node.sessionId} name={label} size={18} />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700 dark:text-gray-300">
{label}
</span>
{/* Elapsed: ticking from first appearance while running, frozen at the settled
span when done; omitted when the stamps are unknown (always for the root).
Decorative next to the label — the aria-label pins the accessible name. */}
{node.running
? node.startedMs !== undefined && (
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
<LiveDuration sinceMs={node.startedMs} />
</span>
)
: node.elapsedMs !== undefined && (
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
{humanizeDuration(node.elapsedMs)}
</span>
)}
{/* Status is already part of the button's accessible name: keep the glyph decorative. */}
<StatusIcon state={node.running ? "running" : "done"} size={10} />
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,370 @@
/**
* Subagent topology of one Task: pure extraction + layout, no React/DOM (unit-tested in
* test/agent-topology.test.ts; rendering lives in agent-topology-view.tsx — same split as the
* usage charts' chart-geom.ts / chart-svg.tsx).
*
* Extraction walks a single Task's slice of the main model — by default the LATEST Task (from
* the last user_text / user_image item — the same "starts a new Task" predicate the reducer
* uses; user_steering never starts one), or, for a chip clicked on an older turn, the
* HISTORICAL Task slice containing that chip's child (extractTopologyForChild). It collects
* spawned children from bound run_subagent tool cards and standalone SubagentItems, then
* recurses into each child model's own items for deeper spawns. Nodes are deduped by session id
* (an input_subagent repoll can reference the same child twice); a new Task therefore starts
* from a graph of just the root again. Each child node also carries its elapsed-time sources
* (from the nested model's activity stamps — wall clock of the whole spawn, never a sum of its
* items).
*
* Layout is a simple layered tree: depth = column (left to right), leaves get successive rows
* and a parent centers on its children; edges are orthogonal elbows between node midlines.
* Node boxes are fixed-size (labels truncate inside them), which keeps the layout free of any
* text measurement; graphs are small (≲15 nodes) and the view scrolls horizontally on overflow.
*/
import type { ChatItem, StreamModel } from "../../lib/omni/stream-model";
export interface TopologyNode {
sessionId: string;
/** Agent running this node: the child's session_meta capture first, else the run_subagent `agent_id` argument; null when unknown (root: filled by the view from the Session DTO). */
agentId: string | null;
/** Still running — the spawning card's output hasn't completed (root: the Task's own running state; a standalone item has no card, so it reads as done). */
running: boolean;
/** 0 = the main session; +1 per spawn hop. */
depth: number;
/** Origin chain used to render this node's conversation as ctx.origin (ends with this node's own session id; empty for the root). */
origin: string[];
/** Parent node's session id; null for the root. */
parentId: string | null;
/**
* Tick baseline while the node runs: when the child first appeared, preferring its first
* message timestamp (survives a reload — a mid-run refresh keeps counting from the real spawn
* moment) over the live wall-clock stamp (only meaningful while this client watched live).
* Undefined for the root (it carries no stamps) and when nothing is known — the view then
* omits the duration.
*/
startedMs?: number;
/**
* Settled span for a finished node: first→last activity of the child's subtree, derived from
* message timestamps so a history reload reproduces the same value (the local stamp pair is
* only a fallback for unparseable timestamps — after a replay it would collapse to ~0).
* Undefined for the root/unknown — omitted by the view.
*/
elapsedMs?: number;
}
/** Index of the latest Task's first item (the last user_text/user_image — the reducer's "starts a new Task" predicate); 0 when there is none (mid-stream join). */
export function latestTaskStart(items: readonly ChatItem[]): number {
for (let i = items.length - 1; i >= 0; i--) {
const kind = items[i]!.kind;
if (kind === "user_text" || kind === "user_image") return i;
}
return 0;
}
/**
* Count of Task-starting user items (the reducer's startTask predicate: user_text/user_image —
* steering chips and internal injections such as compaction summaries never become such items,
* so they never count). The subagents panel's task-boundary signal: an INCREASE means the user
* started a new Task in this session (see advancePanelTaskScope in use-subagents-panel.ts).
*/
export function taskStartCount(items: readonly ChatItem[]): number {
let n = 0;
for (const item of items) {
if (item.kind === "user_text" || item.kind === "user_image") n += 1;
}
return n;
}
/** Lenient `agent_id` extraction from run_subagent arguments (complete JSON by the time a child is bound; unparseable/absent → null). */
export function agentIdFromRunSubagentArgs(argsJson: string): string | null {
try {
const parsed: unknown = JSON.parse(argsJson);
if (parsed !== null && typeof parsed === "object") {
const id = (parsed as Record<string, unknown>)["agent_id"];
if (typeof id === "string" && id.length > 0) return id;
}
} catch {
// Arguments still streaming or malformed: no agent id to offer.
}
return null;
}
/** Extract the latest Task's spawn tree: root first, then children in DFS preorder (document order). */
export function extractTopology(
model: StreamModel,
rootSessionId: string,
taskRunning: boolean,
): TopologyNode[] {
return extractFromSlice(model.items.slice(latestTaskStart(model.items)), rootSessionId, {
running: taskRunning,
});
}
/**
* Historical variant: extract the spawn tree of the Task slice CONTAINING the given direct
* child — the slice around the main-stream item that references it (a bound run_subagent card
* or a standalone SubagentItem), bounded by the nearest user_text/user_image on either side.
* Null when no main-stream item references the child (e.g. a resync swapped in a fresh model) —
* the caller then falls back to the latest Task. The root's running state stays live only while
* the slice is still the open-ended latest Task; an older Task has ended by definition (the
* next user message closed it).
*/
export function extractTopologyForChild(
model: StreamModel,
rootSessionId: string,
taskRunning: boolean,
childSessionId: string,
): TopologyNode[] | null {
const items = model.items;
const at = items.findIndex(
(it) =>
(it.kind === "tool_call" && it.subagentSessionId === childSessionId) ||
(it.kind === "subagent" && it.sessionId === childSessionId),
);
if (at === -1) return null;
// Slice bounds: the nearest Task-starting user item at or before the reference (falling back
// to 0 on a mid-stream join with no user item), and the next one after it (end of stream for
// the latest Task). Same "starts a new Task" predicate as latestTaskStart.
let start = 0;
for (let i = at; i >= 0; i--) {
const kind = items[i]!.kind;
if (kind === "user_text" || kind === "user_image") {
start = i;
break;
}
}
let end = items.length;
for (let i = at + 1; i < items.length; i++) {
const kind = items[i]!.kind;
if (kind === "user_text" || kind === "user_image") {
end = i;
break;
}
}
return extractFromSlice(items.slice(start, end), rootSessionId, {
running: end === items.length && taskRunning,
});
}
/** Shared walker behind both extractions: builds the node list from one Task slice of the main items. */
function extractFromSlice(
slice: readonly ChatItem[],
rootSessionId: string,
root: { running: boolean },
): TopologyNode[] {
const nodes: TopologyNode[] = [
{
sessionId: rootSessionId,
agentId: null,
running: root.running,
depth: 0,
origin: [],
parentId: null,
},
];
const seen = new Set<string>([rootSessionId]);
const addChild = (
sessionId: string,
child: StreamModel,
running: boolean,
parentId: string,
parentOrigin: string[],
argsAgentId: string | null,
): void => {
if (seen.has(sessionId)) return;
seen.add(sessionId);
const origin = [...parentOrigin, sessionId];
// Elapsed-time sources (see the StreamModel stamp docs): message timestamps first — they
// survive a reload — with the live wall-clock stamps as the unparseable-timestamp fallback.
const startedMs = child.firstTsMs ?? child.firstSeenLocalMs;
const elapsedMs =
child.firstTsMs !== undefined && child.lastActivityTsMs !== undefined
? Math.max(0, child.lastActivityTsMs - child.firstTsMs)
: child.firstSeenLocalMs !== undefined && child.lastActivityLocalMs !== undefined
? Math.max(0, child.lastActivityLocalMs - child.firstSeenLocalMs)
: undefined;
nodes.push({
sessionId,
agentId: child.meta?.agentId ?? argsAgentId,
running,
depth: origin.length,
origin,
parentId,
...(startedMs !== undefined ? { startedMs } : {}),
...(elapsedMs !== undefined ? { elapsedMs } : {}),
});
// Deeper spawns live in the child model's own items (a child conversation is never Task-sliced — all of it belongs to this spawn).
walk(child.items, sessionId, origin);
};
const walk = (items: readonly ChatItem[], parentId: string, parentOrigin: string[]): void => {
for (const item of items) {
if (item.kind === "tool_call" && item.subagent && item.subagentSessionId) {
addChild(
item.subagentSessionId,
item.subagent,
!item.outputComplete,
parentId,
parentOrigin,
agentIdFromRunSubagentArgs(item.argumentsText),
);
} else if (item.kind === "subagent") {
addChild(item.sessionId, item.model, false, parentId, parentOrigin, null);
}
}
};
walk(slice, rootSessionId, []);
return nodes;
}
/**
* Whether the LATEST Task's slice references any spawned child (a bound run_subagent card or a
* standalone SubagentItem). The chat page's auto-open trigger: combined with the running task
* state, it fires only for a LIVE spawn in the current turn — children from earlier Tasks (or a
* finished conversation opened from history) never pop the panel open.
*/
export function latestTaskHasSubagent(model: StreamModel): boolean {
const items = model.items;
for (let i = latestTaskStart(items); i < items.length; i++) {
const item = items[i]!;
if ((item.kind === "tool_call" && item.subagent !== undefined) || item.kind === "subagent") {
return true;
}
}
return false;
}
/** Walk the nested-model chain (`origin` ends with the target's own session id); null when a hop is missing or the chain is empty. */
export function modelAtOrigin(model: StreamModel, origin: readonly string[]): StreamModel | null {
if (origin.length === 0) return null;
let cur: StreamModel = model;
for (const hop of origin) {
const next = cur.subagents.get(hop);
if (!next) return null;
cur = next;
}
return cur;
}
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
/** Fixed node box (avatar + truncated name + elapsed time + status glyph) — no text measurement, so the layout stays pure. */
export const NODE_W = 168;
export const NODE_H = 34;
export const GAP_X = 32;
export const GAP_Y = 10;
export const PAD = 6;
export interface PlacedNode {
node: TopologyNode;
/** Top-left corner of the node box (CSS pixels within the layout canvas). */
x: number;
y: number;
}
export interface PlacedEdge {
fromId: string;
toId: string;
/** SVG path between the two node midlines: straight when aligned, orthogonal elbow otherwise. */
path: string;
}
export interface TopologyLayout {
width: number;
height: number;
nodes: PlacedNode[];
edges: PlacedEdge[];
}
/** Layered tree layout over extractTopology's output (root included; child order = extraction order). */
export function layoutTopology(nodes: readonly TopologyNode[]): TopologyLayout {
const childrenOf = new Map<string, TopologyNode[]>();
for (const n of nodes) {
if (n.parentId === null) continue;
const list = childrenOf.get(n.parentId);
if (list) list.push(n);
else childrenOf.set(n.parentId, [n]);
}
// Leaves take successive rows; a parent centers on its first and last child (rows can be fractional).
const rowOf = new Map<string, number>();
let nextLeafRow = 0;
const assignRow = (node: TopologyNode): number => {
const kids = childrenOf.get(node.sessionId) ?? [];
let row: number;
if (kids.length === 0) {
row = nextLeafRow++;
} else {
const rows = kids.map(assignRow);
row = (rows[0]! + rows[rows.length - 1]!) / 2;
}
rowOf.set(node.sessionId, row);
return row;
};
for (const n of nodes) if (n.parentId === null) assignRow(n);
const placed: PlacedNode[] = nodes.map((node) => ({
node,
x: PAD + node.depth * (NODE_W + GAP_X),
y: PAD + (rowOf.get(node.sessionId) ?? 0) * (NODE_H + GAP_Y),
}));
const byId = new Map(placed.map((p) => [p.node.sessionId, p]));
const edges: PlacedEdge[] = [];
for (const p of placed) {
if (p.node.parentId === null) continue;
const from = byId.get(p.node.parentId);
if (!from) continue;
const x1 = from.x + NODE_W;
const y1 = from.y + NODE_H / 2;
const x2 = p.x;
const y2 = p.y + NODE_H / 2;
const midX = x1 + GAP_X / 2;
edges.push({
fromId: from.node.sessionId,
toId: p.node.sessionId,
path: y1 === y2 ? `M ${x1} ${y1} H ${x2}` : `M ${x1} ${y1} H ${midX} V ${y2} H ${x2}`,
});
}
const maxDepth = nodes.reduce((acc, n) => Math.max(acc, n.depth), 0);
const rows = Math.max(1, nextLeafRow);
return {
width: PAD * 2 + (maxDepth + 1) * NODE_W + maxDepth * GAP_X,
height: PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y,
nodes: placed,
edges,
};
}
// ---------------------------------------------------------------------------
// Display-name resolution
// ---------------------------------------------------------------------------
/**
* Resolve a node's display label from the loaded lists: agent id (session_meta capture /
* run_subagent argument, else the session list row's agentId) → the project agents list's
* display name (name, with an EMPTY name treated as missing, falling back to the agent id
* itself) → the child session's title.
* Null when nothing is known — the caller falls back to a generic label plus the short session id.
*/
export function resolveAgentLabel(
node: { sessionId: string; agentId: string | null },
agents: readonly { agentId: string; name?: string }[],
sessions: readonly { sessionId: string; agentId: string; title?: string }[],
): string | null {
const row = sessions.find((s) => s.sessionId === node.sessionId);
const agentId = node.agentId ?? row?.agentId ?? null;
if (agentId !== null) {
const agent = agents.find((a) => a.agentId === agentId);
return agent ? (agent.name?.length ? agent.name : agent.agentId) : agentId;
}
return row?.title ?? null;
}
/** Short session-id suffix for chips and node subtitles (ids end in a hex run — the tail is the distinctive part). */
export function shortSessionId(sessionId: string): string {
return sessionId.length > 8 ? sessionId.slice(-8) : sessionId;
}
+132 -6
View File
@@ -1,10 +1,13 @@
/**
* Chat page (refactored version):
* a thin top toolbar (Session title / status / iconized stats / Files toggle / details popup) +
* the message stream and input area (input box vertically centered when there are no messages).
* a thin top toolbar (Session title / status / iconized stats / Agents + Files toggles /
* details popup) + the message stream and input area (input box vertically centered when there
* are no messages).
* Files is no longer a mutually exclusive tab — it's a persistent, closable, resizable docked
* panel on the right (use-files-panel.ts), and each message's trailing file summary card jumps to
* and locates the file in the tree via onOpenFile.
* and locates the file in the tree via onOpenFile. The subagents panel docks the same way
* (use-subagents-panel.ts): subagent chips in the stream open it focused via onOpenSubagent,
* and the two docked panels are mutually exclusive (coordinated here, not in the hooks).
* Approval mode and Model/context usage live in the input area's toolbar; context is compacted
* via the /compact slash command.
* Draft state (/chat/new) is carried by DraftView: Agent / Workspace / approval mode / Model are
@@ -52,6 +55,7 @@ import { EmptyState } from "../../components/ui/empty-state";
import { toastError } from "../../components/ui/toast";
import { MessageStream } from "./message-stream";
import type { StreamRenderContext } from "./message-stream";
import { latestTaskHasSubagent, taskStartCount } from "./agent-topology";
import { ChatInput } from "./chat-input";
import { DraftView } from "./draft-view";
import { GoalStatusBanner } from "./goal-banner";
@@ -60,6 +64,14 @@ import { sameModelRef } from "../models/model-grouping";
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
import { FilesPanel } from "./files-panel";
import { useFilesPanel } from "./use-files-panel";
import type { FilesPanelState } from "./use-files-panel";
import { SubagentsPanel } from "./subagents-panel";
import {
advancePanelTaskScope,
createPanelTaskScope,
useSubagentsPanel,
} from "./use-subagents-panel";
import type { SubagentsPanelState } from "./use-subagents-panel";
import { useSessionDraft } from "./use-session-draft";
import { useSessionStream } from "./use-session-stream";
@@ -234,7 +246,29 @@ export function ChatPage() {
const [turnThinkingLevel, setTurnThinkingLevel] = useState("");
const routeSessionId = params.sessionId ?? null;
const filesPanel = useFilesPanel(routeSessionId);
const filesPanelRaw = useFilesPanel(routeSessionId);
const subagentsPanelRaw = useSubagentsPanel(routeSessionId);
// The two docked panels are MUTUALLY EXCLUSIVE — side by side they'd crush the chat column at
// the 1024px breakpoint (and two stacked Sheets on mobile would be worse). Exclusivity is
// enforced here, on the panel objects every consumer receives, rather than at each call site:
// ANY path that opens one panel (toolbar toggles, message file cards via onOpenFile, subagent
// chips via onOpenSubagent, or a future caller) closes the other as a side effect of
// setOpen(true). Closing never cascades. The hooks stay uncoordinated on purpose — they don't
// know about each other; only this page, which owns both, does.
const filesPanel: FilesPanelState = {
...filesPanelRaw,
setOpen: (next: boolean) => {
if (next) subagentsPanelRaw.setOpen(false);
filesPanelRaw.setOpen(next);
},
};
const subagentsPanel: SubagentsPanelState = {
...subagentsPanelRaw,
setOpen: (next: boolean) => {
if (next) filesPanelRaw.setOpen(false);
subagentsPanelRaw.setOpen(next);
},
};
const draft = routeSessionId === DRAFT_SESSION_ID;
const selected = draft ? null : (sessions.find((s) => s.sessionId === routeSessionId) ?? null);
// Currently effective model (session state, the model reference comes from the Session DTO): model selection in draft state is handled internally by DraftView.
@@ -276,6 +310,41 @@ export function ChatPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSessionId, selectedAgentId, setCurrentAgentId]);
// TASK-SCOPED panel visibility (owner rule: an open panel belongs to the task it was opened
// for). The pure tracker (advancePanelTaskScope, unit-tested) decides at each observation:
// - entering a session / a NEW Task starting (a user message — taskStartCount increase)
// closes the panel by default, so an unrelated task never inherits it;
// - the CURRENT task's first live spawn auto-opens it (re-armed per task; a manual close
// afterwards is respected until the next boundary).
// The auto-open applies only when docked (a mobile Sheet sliding over the conversation
// uninvited would be worse than staying discoverable via the row), never over an open Files
// panel (an automatic open must not steal an explicit one — the row and the toolbar's amber
// dot still signal), and never re-triggers an already-open panel (that would yank a pinned
// historical graph back to the latest Task); the tracker consumes the attempt regardless.
const panelTaskScopeRef = useRef(createPanelTaskScope());
const taskCount = taskStartCount(stream.model.items);
const liveSpawn = stream.taskState !== "idle" && latestTaskHasSubagent(stream.model);
useEffect(() => {
const action = advancePanelTaskScope(panelTaskScopeRef.current, {
sessionId: selectedSessionId,
taskCount,
liveSpawn,
});
if (action === "close") {
subagentsPanelRaw.setOpen(false);
} else if (
action === "autoOpen" &&
subagentsPanelRaw.isDocked &&
!subagentsPanelRaw.open &&
!filesPanelRaw.open
) {
subagentsPanelRaw.setOpen(true);
}
// The panel objects are rebuilt every render; the tracker only acts on real transitions of
// these three observed values.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSessionId, taskCount, liveSpawn]);
// Skills installed on the session's Agent (candidates for the input area's skill dropdown):
// fetched keyed on the session's Agent; on switch, cleared first (which also clears the input
// area's selection) before refetching; a failed fetch is silently treated as no skills.
@@ -731,14 +800,27 @@ export function ChatPage() {
onOpenFile: (path) => {
// The file card has already normalized the text path to a Workspace-relative path
// (toWorkspaceRelative, including stripping absolute-path prefixes and converting Windows
// separators), so this just opens the panel and navigates to it directly.
// separators), so this just opens the panel and navigates to it directly (the wrapped
// setOpen already closes the subagents panel — see the exclusivity block above).
filesPanel.setOpen(true);
filesPanel.browsePath(path);
},
onOpenSubagent: (sessionId, origin) => {
// Chip click: open the panel focused on that child (the focus chain ends with the child's
// own id; the wrapped setOpen closes the Files panel). focusSubagent after setOpen: the
// open resets the Task scope to "latest", and the focus then pins it to this chip's Task.
subagentsPanel.setOpen(true);
subagentsPanel.focusSubagent(sessionId, [...origin, sessionId]);
},
workspace: selected?.workspace ?? null,
statFiles,
};
// Any pending approval sitting inside a subagent (approvalKey = "originChain toolCallId";
// main-session keys start with a space): surfaces an amber dot on the toolbar button so a
// nested approval stays discoverable while the panel is closed.
const anySubagentPending = [...stream.pendingApprovals.keys()].some((k) => !k.startsWith(" "));
if (!projectId || !agentId) {
return (
<div className="p-6">
@@ -862,7 +944,41 @@ export function ChatPage() {
<StatChip icon={STAT_ICONS.elapsed} value={hs.elapsedNode} label={S.chat.statElapsed} />
</div>
{/* Files panel toggle: docks on the right of the chat instead of replacing it full-screen (use-files-panel.ts). */}
{/* Subagents panel toggle: latest-Task call graph + child conversations dock on the right (use-subagents-panel.ts); opening closes the Files panel (wrapped setOpen). */}
<button
type="button"
aria-expanded={subagentsPanel.open}
onClick={() => subagentsPanel.setOpen(!subagentsPanel.open)}
title={S.chat.openAgents}
className={`flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors duration-150 ${
subagentsPanel.open
? "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
}`}
>
{/* Nodes/network glyph (spawn tree). */}
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden
>
<circle cx="5" cy="12" r="2.5" />
<circle cx="19" cy="5.5" r="2.5" />
<circle cx="19" cy="18.5" r="2.5" />
<path d="M7.4 11 16.7 6.6M7.4 13l9.3 4.4" />
</svg>
{S.chat.openAgents}
{/* A pending approval inside a subagent: amber dot (the chip in the stream carries the accessible announcement). */}
{anySubagentPending && (
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-amber-500" />
)}
</button>
{/* Files panel toggle: docks on the right of the chat instead of replacing it full-screen (use-files-panel.ts); opening closes the subagents panel (wrapped setOpen). */}
<button
type="button"
aria-expanded={filesPanel.open}
@@ -1038,6 +1154,16 @@ export function ChatPage() {
)}
</div>
{selected && (
<SubagentsPanel
session={selected}
panel={subagentsPanel}
model={stream.model}
version={stream.version}
taskRunning={stream.taskState !== "idle"}
ctx={ctx}
/>
)}
{selected && <FilesPanel session={selected} panel={filesPanel} />}
</div>
@@ -17,7 +17,7 @@ import { ZoomableImage } from "../../components/ui/image-zoom";
import { MessageFilesCard } from "./message-files-card";
import { ThinkingBlock } from "./thinking-block";
import { ToolCallCard } from "./tool-call-card";
import { SubagentCard } from "./subagent-card";
import { SubagentChip } from "./subagent-chip";
import { CompactionBanner } from "./compaction-banner";
import { GoalRoundBanner } from "./goal-banner";
import { HandoffBanner, ModelSwitchBanner } from "./handoff-banner";
@@ -315,9 +315,11 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
case "tool_call":
return <ToolCallCard item={item} ctx={ctx} />;
case "subagent":
// Standalone child session (no run_subagent card to bind to): same full-width shortcut
// bar as the bound site — the conversation itself lives in the subagents panel.
return (
<div className="anim-msg my-2">
<SubagentCard sessionId={item.sessionId} model={item.model} running={false} ctx={ctx} />
<SubagentChip sessionId={item.sessionId} model={item.model} running={false} ctx={ctx} />
</div>
);
case "abort":
@@ -43,6 +43,8 @@ export interface StreamRenderContext {
onGiveUp?: () => void;
/** Opens the Files panel and navigates to this file (triggered by clicking the file-summary card at the end of a message; takes a Workspace-relative path); the card doesn't render if this isn't wired up. */
onOpenFile?: (path: string) => void;
/** Opens the subagents panel focused on this child session (subagent chip click); `origin` is the ctx.origin at the chip's render level — the child's ancestor chain, excluding its own id. */
onOpenSubagent?: (sessionId: string, origin: string[]) => void;
/** Absolute Workspace path of the current Session (used by the file-summary card to normalize body paths). */
workspace?: string | null;
/** Batch file-existence check (with session-level caching); the card doesn't render if this isn't wired up. */
@@ -1,71 +0,0 @@
/**
* Subagent card: nested inside a run_subagent card (or as a
* standalone card), renders the subsession's full streaming process by following the origin
* chain — expanded by default while running, collapsed once finished; nesting inside the card
* uses the same rendering recursively.
*/
import { useEffect, useRef, useState } from "react";
import { S } from "../../lib/strings";
import type { StreamModel } from "../../lib/omni/stream-model";
import { Chevron } from "../../components/ui/chevron";
import { MessageItems } from "./message-stream";
import type { StreamRenderContext } from "./message-stream";
export function SubagentCard({
sessionId,
model,
running,
ctx,
}: {
sessionId: string;
model: StreamModel;
running: boolean;
ctx: StreamRenderContext;
}) {
const [open, setOpen] = useState(running);
const userToggled = useRef(false);
const wasRunning = useRef(running);
// Append this card's subsession id to the nesting level: tool cards within the card match
// pending approvals by the full origin chain; the running state inside the subsession follows
// this card's own running prop, not the parent session's taskRunning.
const nestedCtx: StreamRenderContext = {
...ctx,
...(sessionId ? { origin: [...ctx.origin, sessionId] } : {}),
taskRunning: running,
};
useEffect(() => {
// Auto-collapse once finished (respects the user's choice if they manually toggled it).
if (wasRunning.current && !running && !userToggled.current) setOpen(false);
wasRunning.current = running;
}, [running]);
return (
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50/60 dark:border-gray-700 dark:bg-gray-900/40">
<button
type="button"
onClick={() => {
userToggled.current = true;
setOpen((v) => !v);
}}
className="flex w-full items-center gap-2 rounded-t-md px-3 py-1.5 text-left text-xs text-gray-500 transition-colors duration-200 hover:bg-gray-100/70 dark:text-gray-400 dark:hover:bg-gray-800/50"
>
<Chevron open={open} />
<span className="font-semibold text-gray-700 dark:text-gray-300">{S.chat.subagent}</span>
{sessionId && <span className="truncate font-mono text-gray-400">{sessionId}</span>}
{running && (
<span className="ml-auto flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span className="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-gray-400 border-t-transparent" />
{S.chat.subagentRunning}
</span>
)}
</button>
{open && (
<div className="anim-fade border-t border-dashed border-gray-200 px-3 py-1 dark:border-gray-800">
<MessageItems items={model.items} ctx={nestedCtx} />
</div>
)}
</div>
);
}
@@ -0,0 +1,72 @@
/**
* Subagent shortcut row: the only trace a spawned child session leaves in the main chat flow
* (the full nested conversation lives in the subagents side panel). A full-width bar styled
* like the stream's other collapsed rows (the thinking block / work-group header idiom:
* rounded border, muted background, left-aligned content) — agent avatar + resolved agent name
* + short session-id suffix — with a spinner while the child runs and an amber dot (also
* announced in the accessible name) while any approval is pending anywhere in the child's
* subtree, so a nested approval stays discoverable with the panel closed. Clicking opens the
* panel focused on this child via ctx.onOpenSubagent.
*/
import { S } from "../../lib/strings";
import { hasPendingWithinOrigin } from "../../lib/omni/stream-model";
import type { StreamModel } from "../../lib/omni/stream-model";
import { AgentAvatar } from "../../components/ui/agent-avatar";
import { useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
import { resolveAgentLabel, shortSessionId } from "./agent-topology";
import type { StreamRenderContext } from "./message-stream";
export function SubagentChip({
sessionId,
model,
running,
ctx,
agentId = null,
}: {
sessionId: string;
model: StreamModel;
running: boolean;
ctx: StreamRenderContext;
/** Agent id from the spawning call's arguments (bound tool-card site); the child's own session_meta capture takes priority. */
agentId?: string | null;
}) {
const { agents } = useProject();
const { sessions } = useSessions();
const label =
resolveAgentLabel({ sessionId, agentId: model.meta?.agentId ?? agentId }, agents, sessions) ??
S.chat.subagent;
const pending = hasPendingWithinOrigin(ctx.pendingApprovals.keys(), [...ctx.origin, sessionId]);
const name = `${S.chat.subagent} ${label}${pending ? ` · ${S.chat.approvalWaiting}` : ""}`;
return (
// Full-width bar, same visual family as the work-group header / thinking row (rounded-md
// border + muted background + px-3 py-2 left-aligned content) — not a compact pill, so the
// row lines up with the stream's other collapsed step rows.
<button
type="button"
aria-label={name}
title={name}
onClick={() => ctx.onOpenSubagent?.(sessionId, ctx.origin)}
className="flex w-full items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-left transition-colors duration-150 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900/60 dark:hover:bg-gray-800/60"
>
<AgentAvatar id={model.meta?.agentId ?? agentId ?? sessionId} name={label} size={16} />
<span className="min-w-0 truncate text-xs font-medium text-gray-700 dark:text-gray-300">
{label}
</span>
{sessionId && (
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
{shortSessionId(sessionId)}
</span>
)}
{running && (
<span
aria-hidden
className="inline-block h-2.5 w-2.5 shrink-0 animate-spin rounded-full border border-gray-400 border-t-transparent"
/>
)}
{pending && <span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500" />}
<span className="min-w-0 flex-1" />
</button>
);
}
@@ -0,0 +1,108 @@
/**
* Subagents panel shell (same docked/Sheet split as files-panel.tsx, which documents the
* clipping-window and inert rationale in detail): on desktop (≥1024px) it docks to the right of
* the chat with a drag-to-resize edge; on narrower viewports it becomes a bottom Sheet. Content
* is SubagentsView (latest-Task call graph + the selected child conversation), keyed by session
* so node selection resets on session switch.
*/
import type { SessionInfo } from "@prismshadow/penguin-server/api";
import { S } from "../../lib/strings";
import type { StreamModel } from "../../lib/omni/stream-model";
import { Sheet } from "../../components/ui/sheet";
import { SubagentsView } from "./subagents-view";
import type { StreamRenderContext } from "./message-stream";
import type { SubagentsPanelState } from "./use-subagents-panel";
function CloseIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" aria-hidden>
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export function SubagentsPanel({
session,
panel,
model,
version,
taskRunning,
ctx,
}: {
session: SessionInfo;
panel: SubagentsPanelState;
model: StreamModel;
version: number;
taskRunning: boolean;
ctx: StreamRenderContext;
}) {
const view = (
<SubagentsView
key={session.sessionId}
session={session}
model={model}
version={version}
taskRunning={taskRunning}
ctx={ctx}
focusRequest={panel.focusRequest}
taskScope={panel.taskScope}
/>
);
if (!panel.isDocked) {
return (
<Sheet
open={panel.open}
snap={panel.sheetSnap}
onSnapChange={panel.setSheetSnap}
onClose={() => panel.setOpen(false)}
title={S.subagentPanel.title}
>
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">{view}</div>
</div>
</Sheet>
);
}
return (
<>
{panel.open && (
<div
onMouseDown={panel.startResize}
onDoubleClick={panel.resetWidth}
title={S.files.resizeHandle}
className={`w-1.5 shrink-0 cursor-col-resize transition-colors duration-150 hover:bg-brand-300/50 dark:hover:bg-brand-700/40 ${
panel.resizing ? "bg-brand-400/60" : "bg-transparent"
}`}
/>
)}
<div
ref={panel.panelRef}
style={{ width: panel.open ? panel.width : 0 }}
// Same inert + clipping-window handling as the Files panel (see files-panel.tsx).
inert={!panel.open}
className={`relative flex min-h-0 shrink-0 flex-col overflow-hidden border-l border-gray-200 dark:border-gray-800 ${
panel.resizing ? "pointer-events-none" : "transition-[width] duration-200"
}`}
>
<div style={{ width: panel.width }} className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center gap-1 px-3 pt-2">
<h4 className="min-w-0 flex-1 truncate text-sm font-semibold">
{S.subagentPanel.title}
</h4>
<button
type="button"
onClick={() => panel.setOpen(false)}
title={S.common.close}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<CloseIcon />
</button>
</div>
<div className="min-h-0 flex-1">{view}</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,202 @@
/**
* Subagents panel content: one Task's call graph on top (root = the main session, one node per
* spawned child; clicking a node switches the conversation below) and the selected child's
* live conversation underneath, rendered with the SAME machinery as the main chat
* (MessageStream over the child StreamModel with ctx.origin set to the child's chain) — so
* streaming, nested tool cards, chips for deeper spawns, and pending approvals all keep
* working inside the panel.
*
* Which Task: the LATEST by default (toolbar open / session switch — a new Task then resets
* the graph to its own agents as it streams); a chip click pins `taskScope` to that chip's
* Task instead (use-subagents-panel.ts), so a chip on an older turn shows that turn's
* HISTORICAL graph via extractTopologyForChild — falling back to the latest Task when the
* anchor is no longer referenced (e.g. a resync swapped the model).
*
* Selection: an explicit chip click (focusRequest) or a node click wins; with no explicit
* choice the first child of the displayed Task is auto-focused (so opening the panel while a
* subagent is spawning immediately shows it). Selecting the root shows a note instead of
* duplicating the main transcript. A focused child outside the displayed Task keeps its
* conversation visible (found by walking the origin chain); the graph simply has no
* highlighted node.
*/
import { useEffect, useMemo, useState } from "react";
import type { SessionInfo } from "@prismshadow/penguin-server/api";
import { S } from "../../lib/strings";
import type { StreamModel } from "../../lib/omni/stream-model";
import { AgentAvatar } from "../../components/ui/agent-avatar";
import { EmptyState } from "../../components/ui/empty-state";
import { StatusIcon } from "../../components/ui/status-icon";
import { useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
import {
extractTopology,
extractTopologyForChild,
modelAtOrigin,
resolveAgentLabel,
shortSessionId,
} from "./agent-topology";
import type { TopologyNode } from "./agent-topology";
import { AgentTopologyView } from "./agent-topology-view";
import { MessageStream } from "./message-stream";
import type { StreamRenderContext } from "./message-stream";
interface Selection {
sessionId: string;
/** ctx.origin chain of the selected conversation (ends with its own session id; empty = the root/main session). */
origin: string[];
}
export function SubagentsView({
session,
model,
version,
taskRunning,
ctx,
focusRequest,
taskScope,
}: {
session: SessionInfo;
model: StreamModel;
/** View-model version (repaint signal): keys the topology recompute and drives MessageStream's auto-follow. */
version: number;
taskRunning: boolean;
/** Main-session render context (origin []): the child conversation derives its own from it. */
ctx: StreamRenderContext;
focusRequest: Selection | null;
/** Displayed Task scope (see use-subagents-panel.ts): null = latest; an anchor pins the historical Task containing that child. */
taskScope: { anchorSessionId: string } | null;
}) {
const { agents } = useProject();
const { sessions } = useSessions();
const [selected, setSelected] = useState<Selection | null>(null);
// A chip click focuses that child (fresh-object identity: re-clicking the same chip re-fires).
useEffect(() => {
if (focusRequest) {
setSelected({ sessionId: focusRequest.sessionId, origin: focusRequest.origin });
}
}, [focusRequest]);
const nodes = useMemo(
() =>
// A pinned scope (chip click) shows THAT Task's graph — historical or latest alike; when
// its anchor is no longer referenced in the stream, fall back to the latest Task.
(taskScope
? extractTopologyForChild(model, session.sessionId, taskRunning, taskScope.anchorSessionId)
: null) ?? extractTopology(model, session.sessionId, taskRunning),
// version is the model's change signal (items mutate in place).
// eslint-disable-next-line react-hooks/exhaustive-deps
[model, version, session.sessionId, taskRunning, taskScope],
);
// No explicit selection yet: auto-focus the displayed Task's first child (null when none spawned).
const firstChild = nodes.find((n) => n.depth > 0) ?? null;
const active: Selection | null =
selected ??
(firstChild ? { sessionId: firstChild.sessionId, origin: firstChild.origin } : null);
const labelFor = (node: TopologyNode): string => {
if (node.depth === 0) {
const agent = agents.find((a) => a.agentId === session.agentId);
// Same empty-name rule as resolveAgentLabel: an empty display name falls back to the id.
return agent ? (agent.name?.length ? agent.name : agent.agentId) : session.agentId;
}
// Last-resort label is the short session id, not a generic word: two unknown children must stay distinguishable in the graph.
return resolveAgentLabel(node, agents, sessions) ?? shortSessionId(node.sessionId);
};
const isRoot = active !== null && active.origin.length === 0;
const activeNode = active ? (nodes.find((n) => n.sessionId === active.sessionId) ?? null) : null;
const activeModel = active && !isRoot ? modelAtOrigin(model, active.origin) : null;
const activeLabel = activeNode
? labelFor(activeNode)
: active
? (resolveAgentLabel(
{ sessionId: active.sessionId, agentId: activeModel?.meta?.agentId ?? null },
agents,
sessions,
) ?? S.chat.subagent)
: "";
const activeRunning = activeNode?.running ?? false;
// Same derivation the subagent card used: the child conversation's approval keys and
// "Reasoning & Tools" running state follow the child's chain and its own running flag.
const childCtx: StreamRenderContext | null = active
? { ...ctx, origin: active.origin, taskRunning: activeRunning }
: null;
return (
<div className="flex h-full min-h-0 flex-col">
{/* Call graph of the displayed Task — latest by default, a chip's Task when pinned (capped height; scrolls both ways for deep/wide trees). */}
<div className="shrink-0 border-b border-gray-200 px-3 pb-2 pt-1.5 dark:border-gray-800">
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{S.subagentPanel.topologyLabel}
</p>
{nodes.length > 1 ? (
<div className="max-h-48 overflow-y-auto">
<AgentTopologyView
nodes={nodes}
selectedId={active?.sessionId ?? null}
labelFor={labelFor}
onSelect={(node) => setSelected({ sessionId: node.sessionId, origin: node.origin })}
/>
</div>
) : (
<p className="py-1 text-xs text-gray-400 dark:text-gray-500">{S.subagentPanel.empty}</p>
)}
</div>
{/* Selected conversation (or the root note / empty state). */}
{active === null ? (
<div className="min-h-0 flex-1">
<EmptyState title={S.subagentPanel.empty} />
</div>
) : isRoot ? (
<div className="flex min-h-0 flex-1 items-center justify-center p-4">
<p className="text-sm text-gray-400 dark:text-gray-500">
{S.subagentPanel.mainSessionNote}
</p>
</div>
) : activeModel === null ? (
// The chain broke (e.g. a resync swapped in a fresh model and this child hasn't re-streamed yet).
<div className="min-h-0 flex-1">
<EmptyState title={S.subagentPanel.empty} />
</div>
) : (
<>
{/* Slim identity strip for the conversation below. */}
<div className="flex shrink-0 items-center gap-2 border-b border-gray-100 px-3 py-1.5 dark:border-gray-800/60">
<AgentAvatar
id={activeModel.meta?.agentId ?? active.sessionId}
name={activeLabel}
size={16}
/>
<span className="min-w-0 truncate text-xs font-semibold text-gray-700 dark:text-gray-300">
{activeLabel}
</span>
<span
title={active.sessionId}
className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500"
>
{shortSessionId(active.sessionId)}
</span>
{activeRunning && (
<StatusIcon state="running" size={10} label={S.chat.subagentRunning} />
)}
</div>
<div className="min-h-0 flex-1">
{/* Keyed by child: switching nodes resets scroll-follow instead of carrying the old position over. */}
{childCtx && (
<MessageStream
key={active.sessionId}
items={activeModel.items}
version={version}
ctx={childCtx}
/>
)}
</div>
</>
)}
</div>
);
}
@@ -1,11 +1,10 @@
/**
* Tool call card: collapses to a single line by
* default — status icon + tool name + duration (a live-ticking timer while running) + status
* badge; clicking expands full arguments and output; a nested subagent renders below them
* regardless of collapsed state.
* The pending-approval row is always visible regardless of collapsed state; when a pending
* approval appears anywhere in a nested subagent chain, the card auto-expands once (respecting
* the user's choice if they've manually collapsed it since).
* badge; clicking expands full arguments and output; a bound subagent renders as a full-width
* shortcut row below them regardless of collapsed state (the child conversation itself lives
* in the subagents side panel; the row carries its own pending-approval dot, so the card no
* longer needs to auto-expand for nested approvals).
*
* Duration accounting = **argument-generation segment + execution segment** (excludes time
* spent waiting on human approval): the model streaming out arguments token by token is often
@@ -13,7 +12,7 @@
* understate this step's cost. While waiting on approval, the already-settled generation
* segment is shown, with a separate "Waiting for approval" badge attached.
*/
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { S } from "../../lib/strings";
import { humanizeDuration } from "../../lib/format";
import { approvalKey } from "../../lib/omni/stream-model";
@@ -25,7 +24,8 @@ import { StatusIcon } from "../../components/ui/status-icon";
import type { RunState } from "../../components/ui/status-icon";
import { ApprovalButtons } from "./approval-buttons";
import { LiveDuration } from "./live-duration";
import { SubagentCard } from "./subagent-card";
import { agentIdFromRunSubagentArgs } from "./agent-topology";
import { SubagentChip } from "./subagent-chip";
import type { StreamRenderContext } from "./message-stream";
/** Tools that accept the optional model-written `description` argument. */
@@ -172,21 +172,6 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
// Matched by the current origin chain + toolCallId: prevents parent/child session tool_call_id collisions from lighting each other up.
const pending = ctx.pendingApprovals.get(approvalKey(ctx.origin, item.toolCallId));
// Whether there's a pending approval anywhere in the nested subagent chain (keys are `origin-chain toolCallId`, matched by prefix at any depth).
const nestedPrefix = item.subagentSessionId
? [...ctx.origin, item.subagentSessionId].join("/")
: null;
const hasNestedPending =
nestedPrefix !== null &&
[...ctx.pendingApprovals.keys()].some(
(k) => k.startsWith(`${nestedPrefix} `) || k.startsWith(`${nestedPrefix}/`),
);
// A nested pending approval needs the user's action: auto-expand once (respected if the user manually collapses it afterward).
useEffect(() => {
if (hasNestedPending && !userToggled.current) setOpen(true);
}, [hasNestedPending]);
const preview = previewArguments(item.name, item.argumentsText);
const subtitle = headerSubtitle(item.name, item.argumentsText);
// Executing = the call has finished streaming, output hasn't arrived yet, and it's not waiting on approval (approval wait time doesn't count toward execution).
@@ -357,13 +342,14 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
</div>
)}
{/* Subagent card: always visible (not hidden after completion, only collapsed by default), unaffected by the tool card's collapsed state. Rendered below the expanded arguments/output so the nested conversation reads after the tool call's own content, not between the header and its details; pt-2 keeps a gap from the tinted args/output blocks above. */}
{/* Subagent row: always visible (unaffected by the tool card's collapsed state) below the expanded arguments/output — a full-width shortcut bar into the subagents panel; the nested conversation no longer renders inline. */}
{item.subagent && (
<div className="px-3 pb-2 pt-2">
<SubagentCard
<SubagentChip
sessionId={item.subagentSessionId ?? ""}
model={item.subagent}
running={!item.outputComplete}
agentId={agentIdFromRunSubagentArgs(item.argumentsText)}
ctx={ctx}
/>
</div>
@@ -0,0 +1,254 @@
/**
* Subagents panel state machine (cloned from use-files-panel.ts, which documents the shared
* mechanics in detail): panel open/close, drag-to-resize with its own persisted width key, the
* desktop-dock vs. mobile-Sheet breakpoint, the "focus this child conversation" command driven
* by clicking a subagent chip inside a message, and the displayed Task scope (latest vs. the
* historical Task a chip was clicked on — see taskScope).
*
* VISIBILITY IS TASK-SCOPED — this deliberately diverges from the Files panel's
* open-persists-across-sessions convention: an open panel belongs to the task it was opened
* for. Starting a new Task (a user message) closes it by default, entering a session starts
* closed, and it comes back only via a manual open (toolbar/chip) or the CURRENT task spawning
* a subagent (auto-open, re-armed per task). The pure tracker below
* (createPanelTaskScope/advancePanelTaskScope) owns those boundary decisions; the chat page
* observes the stream and applies its actions.
*
* Width remains a layout preference persisted to localStorage, and the focus command uses the
* fresh-object idiom so clicking the same chip again still re-triggers the panel's focus
* effect.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import type { SheetSnap } from "../../components/ui/sheet";
export interface SubagentsPanelState {
open: boolean;
setOpen: (open: boolean) => void;
/** Snap point for the mobile bottom Sheet; unused in the desktop docked state. */
sheetSnap: SheetSnap;
setSheetSnap: (snap: SheetSnap) => void;
/** Clicking a subagent chip: commands the panel to select this child conversation. `origin` is the full chain ending with the child's own session id. */
focusSubagent: (sessionId: string, origin: string[]) => void;
/** The external focus command produced by focusSubagent; a fresh object per call (identity-compared, so re-clicking the same chip re-triggers the effect). */
focusRequest: { sessionId: string; origin: string[] } | null;
/**
* Which Task's topology the panel displays. Null = the LATEST Task (the default: opening via
* the toolbar resets to it, and so does switching Sessions). A chip click pins the graph to
* the Task containing that chip instead — `anchorSessionId` is the clicked child's TOP-LEVEL
* ancestor (the first origin hop; for deeper chips inside the panel the referencing main-
* stream item belongs to that ancestor), which the view resolves to its Task slice via
* extractTopologyForChild, so a chip on an older turn shows that turn's historical graph.
*/
taskScope: { anchorSessionId: string } | null;
/** Docked at >=1024px (lg); otherwise a bottom Sheet — mounted mutually exclusively. */
isDocked: boolean;
width: number;
resizing: boolean;
startResize: (e: ReactMouseEvent<HTMLDivElement>) => void;
/** Double-clicking the drag handle: width reverts to the window-proportional default. */
resetWidth: () => void;
/** Ref to the docked panel's root node (drag-to-resize measures its right edge). */
panelRef: RefObject<HTMLDivElement | null>;
}
const MIN_WIDTH = 320;
const DOCK_QUERY = "(min-width: 1024px)";
const WIDTH_STORAGE_KEY = "penguin.subagentsPanelWidth";
// ---------------------------------------------------------------------------
// Task-scoped visibility tracker (pure — unit-tested in test/panel-task-scope.test.ts)
// ---------------------------------------------------------------------------
/**
* Mutable tracker state, held in a ref by the chat page. `taskCount` is the number of
* Task-starting user items observed in the session's stream (taskStartCount in
* agent-topology.ts); an increase IS the "user started a new Task" boundary.
*/
export interface PanelTaskScope {
sessionId: string | null;
taskCount: number;
/** Task ordinal (the taskCount value) whose one auto-open attempt was already consumed; null = armed. */
autoOpenedAt: number | null;
}
export function createPanelTaskScope(): PanelTaskScope {
return { sessionId: null, taskCount: 0, autoOpenedAt: null };
}
/**
* Advance the tracker with one observation of the current session's stream and return what the
* panel should do — the whole task-scoped lifecycle in one place:
* - session switch → "close" (a session is entered closed; no inherited open state), unless
* its CURRENT task already has a live spawn, which wins as "autoOpen" (a mid-run entry —
* reload included — counts as the spawn introducing itself);
* - a new Task (taskCount increase) → "close" by default (an unrelated task must not inherit
* an open panel) and RE-ARMS the auto-open;
* - a live spawn in the current task → "autoOpen", at most once per task — a manual close
* afterwards is respected until the next boundary;
* - anything else (steering, compaction, more messages in the same task) → null.
* A taskCount DECREASE is a defensive re-baseline (a resync swapped in a smaller model):
* adopted silently — no boundary, and the auto-open attempt counts as consumed so a rebuild
* can never surprise-reopen a panel the user closed mid-task.
* The caller applies "autoOpen" under its own layout guards (docked, files panel closed, not
* already open); the attempt is consumed here regardless, so a suppressed attempt never
* retriggers within the same task.
*/
export function advancePanelTaskScope(
state: PanelTaskScope,
obs: { sessionId: string | null; taskCount: number; liveSpawn: boolean },
): "close" | "autoOpen" | null {
const switched = obs.sessionId !== state.sessionId;
const newTask = !switched && obs.taskCount > state.taskCount;
const rebaseline = !switched && obs.taskCount < state.taskCount;
if (switched || obs.taskCount !== state.taskCount) {
state.sessionId = obs.sessionId;
state.taskCount = obs.taskCount;
// A real boundary re-arms the auto-open; the defensive re-baseline marks it consumed.
state.autoOpenedAt = rebaseline ? obs.taskCount : null;
}
if (obs.liveSpawn && state.autoOpenedAt !== state.taskCount) {
state.autoOpenedAt = state.taskCount;
return "autoOpen";
}
if (switched || newTask) return "close";
return null;
}
/** Width cap: at most half the window (keeping the chat column usable), plus a hard 720px readability ceiling. */
function maxWidthFor(windowWidth: number): number {
return Math.max(MIN_WIDTH, Math.min(720, Math.round(windowWidth * 0.5)));
}
/** Default width ≈ 1/3 of the window, clamped within the min/max bounds (same proportion as the Files panel). */
function defaultWidthFor(windowWidth: number): number {
return Math.min(maxWidthFor(windowWidth), Math.max(MIN_WIDTH, Math.round(windowWidth * 0.34)));
}
/** Initial width: stored preference (clamped back within the current window's bounds) over the proportional default. */
function initialWidth(): number {
const stored = Number(localStorage.getItem(WIDTH_STORAGE_KEY));
if (!Number.isFinite(stored) || stored <= 0) return defaultWidthFor(window.innerWidth);
return Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, Math.round(stored)));
}
export function useSubagentsPanel(sessionId: string | null): SubagentsPanelState {
const [open, setOpenRaw] = useState(false);
const [sheetSnap, setSheetSnap] = useState<SheetSnap>("half");
const [focusRequest, setFocusRequest] = useState<{
sessionId: string;
origin: string[];
} | null>(null);
const [taskScope, setTaskScope] = useState<{ anchorSessionId: string } | null>(null);
/**
* Opening defaults to browsing intent (Sheet at half) on the LATEST Task's graph;
* focusSubagent's conversation intent — queued in the same batch on the chip path — then
* promotes the snap to full and pins the scope to the chip's Task.
*/
const setOpen = useCallback((next: boolean) => {
if (next) {
setSheetSnap("half");
setTaskScope(null);
}
setOpenRaw(next);
}, []);
const [width, setWidth] = useState(initialWidth);
/** Synchronous mirror of width, read by the mouseup persist step (sidesteps the stale closure). */
const widthRef = useRef(width);
const [resizing, setResizing] = useState(false);
const [isDocked, setIsDocked] = useState(() => window.matchMedia(DOCK_QUERY).matches);
const panelRef = useRef<HTMLDivElement>(null);
// Switching Session resets the focus command and any pinned historical Task scope (both
// pointed into the old session's stream — the new session opens on its latest topology).
// The open/closed state is NOT touched here: visibility is task-scoped and owned by the
// chat page's tracker (advancePanelTaskScope above), which closes on session entry and new
// Tasks, and auto-opens on the current task's first live spawn.
useEffect(() => {
setFocusRequest(null);
setTaskScope(null);
}, [sessionId]);
useEffect(() => {
const mq = window.matchMedia(DOCK_QUERY);
const onChange = (e: MediaQueryListEvent) => setIsDocked(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
const focusSubagent = useCallback((sid: string, origin: string[]) => {
setSheetSnap("full"); // Conversation intent: the child transcript needs the space on mobile
// Pin the graph to this chip's Task (the top-level ancestor anchors the slice lookup) —
// ordered after the setOpen(true) of the same click, so the pin wins the batch.
setTaskScope({ anchorSessionId: origin[0] ?? sid });
setFocusRequest({ sessionId: sid, origin });
}, []);
// Drag-to-resize: identical handling to the Files panel (see use-files-panel.ts for the rationale comments).
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
const rect = panelRef.current?.getBoundingClientRect();
const right = rect ? rect.right : window.innerWidth;
const next = Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, right - e.clientX));
widthRef.current = next;
setWidth(next);
};
const onUp = () => {
setResizing(false);
localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(widthRef.current)));
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [resizing]);
const startResize = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
setResizing(true);
}, []);
const resetWidth = useCallback(() => {
const next = defaultWidthFor(window.innerWidth);
widthRef.current = next;
setWidth(next);
// Clear rather than write the default: the default keeps following the window's proportion.
localStorage.removeItem(WIDTH_STORAGE_KEY);
}, []);
// When the window shrinks, clamp the panel back within the cap (shrinks only, like the Files panel).
useEffect(() => {
const onResize = () => {
setWidth((w) => {
const clamped = Math.min(w, maxWidthFor(window.innerWidth));
if (clamped !== w) widthRef.current = clamped;
return clamped;
});
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return {
open,
setOpen,
sheetSnap,
setSheetSnap,
focusSubagent,
focusRequest,
taskScope,
isDocked,
width,
resizing,
startResize,
resetWidth,
panelRef,
};
}