feat(flow,web): 全链路可视化 P2 — 泳道/并行分支 + 节点活动状态 + 节点实时日志
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 后端 trace/graph.go(395→470):泳道感知游标 —— parents 记住被跳过容器(state_transition)的父链、laneLast 按泳道分别的末节点游标;泳道首节点挂 subagent 根、后续步骤在本泳道内串链;**子代理根不再推进主干游标**(否则 spawn 之后的主干节点会被算成最后一条分支的步骤);node kind 抽具名常量。+ graph_lane_test.go(并行双泳道断言精确边集/嵌套泳道/主干游标不回归/laneOf 环保护)
- 前端新增 flow/{tone,lanes,layout,NodeDetail,LaneSummary}:分组=沿入边反向 BFS 取最近 kind=subagent 祖先(纯 edges,故无需新增 wire 字段);布局=主干沿用 P1 最长路径分层 + 每泳道一条独立横带(带内最长路径深度、逐带下推 y 不重叠、横向滚动);Flow.tsx/FlowGraph.tsx 加泳道框/虚线泳道边/运行闪烁(既有 mu-blink)/选中高亮/图例
- 实时日志:选中态按 node id 保存,随每帧 graph_update 重新 find → 自动显示追加后的 log[];空时区分「运行中(事件随 span 结束回填)」与「未记录事件」,不合成事件
- 未新增 wire 字段 → 无契约变化(因此未动 lib/types.ts / wsParse.ts);<FlowGraphView> 旧签名兼容,Agents/Backthink 页不受影响
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + npm run build(177 模块) + 逻辑测试 lanes 40 / channelStatus 45 / enums 47 / messageSync 54
- ⚠️ 已核实、未伪造的缺口:生产路径当前产不出子代理数据 —— Scheduler.Spawn 全仓仅测试调用方(0 生产)、编排包无 bus 订阅入口、无「派子代理」工具/capability → 泳道要真的亮需先接线;Span.Record 同样无生产调用方 → log[] 恒空(UI 已诚实显示空态)
This commit is contained in:
AskaEth committed 2026-10-03 19:21:10 +08:00
1 parent 49d12d9a64
commit 2c100fe3d9
10 files changed
+1532 -338

No files matched your search

@@ -0,0 +1,75 @@
import type { FlowLane } from "./lanes";
import { FLOW_TONE } from "./tone";
import { Tag } from "../../../components/primitives";
/** LaneSummary lists the parallel sub-agent branches of the current round
* (design/19 §2 活动状态:每个分支的 running/ok/err + 进度). Each row shows the
* branch's live status, its step progress and its start time; clicking a row
* selects the branch root node so the detail panel opens on it. Nothing is
* rendered when no sub-agent has reported — the trunk alone is not a lane. */
export function LaneSummary({
lanes,
onSelect,
}: {
lanes: FlowLane[];
onSelect: (nodeId: string) => void;
}): React.ReactNode {
if (lanes.length === 0) return null;
return (
<div className="space-y-2">
<h3 className="px-1 text-xs font-medium text-[var(--mu-text-lo)]">
并行子代理泳道({lanes.length})
</h3>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
{lanes.map((lane) => (
<LaneRow key={lane.id} lane={lane} onSelect={onSelect} />
))}
</div>
</div>
);
}
/** LaneRow renders one branch: status dot/tag, label, progress bar and counts. */
function LaneRow({
lane,
onSelect,
}: {
lane: FlowLane;
onSelect: (nodeId: string) => void;
}): React.ReactNode {
const tone = FLOW_TONE[lane.status];
const settled = lane.done + lane.failed;
const percent = lane.total > 0 ? Math.round((settled / lane.total) * 100) : 0;
return (
<button
onClick={() => onSelect(lane.id)}
className="flex flex-col gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60 px-3 py-2 text-left transition-colors hover:bg-[var(--mu-surface-4)]"
>
<span className="flex flex-wrap items-center gap-2">
<span
className={`h-2 w-2 shrink-0 rounded-full${lane.status === "running" ? " mu-blink" : ""}`}
style={{ backgroundColor: tone.stroke }}
/>
<span className="text-xs font-medium text-[var(--mu-text-hi)]">{lane.root.label}</span>
{lane.depth > 0 ? <Tag tone="neutral">嵌套 {lane.depth}</Tag> : null}
<Tag tone={tone.tag} className="ml-auto">
{tone.label}
</Tag>
</span>
<span className="flex items-center gap-2 text-[10px] text-[var(--mu-text-lo)]">
<span className="h-1 flex-1 overflow-hidden rounded-[var(--mu-radius-pill)] bg-[var(--mu-surface-4)]">
<span className="block h-full" style={{ width: `${percent}%`, backgroundColor: tone.stroke }} />
</span>
<span className="shrink-0 font-mono">
{settled}/{lane.total} 步
</span>
</span>
<span className="flex flex-wrap gap-2 text-[10px] text-[var(--mu-text-lo)]">
<span>完成 {lane.done}</span>
<span>运行 {lane.running}</span>
<span>失败 {lane.failed}</span>
</span>
</button>
);
}
@@ -0,0 +1,116 @@
import type { FlowNode } from "../../../lib/types";
import { Surface, Tag } from "../../../components/primitives";
import { Icon } from "../../../components/Icon";
import { Notice } from "../../components";
import { FLOW_TONE, statusOf } from "./tone";
/** NodeDetail shows one selected node: its status, its lane (when it belongs to
* a sub-agent branch), the whitelisted span props and the span's accumulated
* events (design/19 §2 节点实时日志). The panel is driven by the live graph, so
* a newer `graph_update` frame replaces the props/log arrays in place and the
* list keeps growing while the round runs — no local buffering, no invented
* entries. */
export function NodeDetail({
node,
laneLabel,
onClose,
}: {
node: FlowNode;
laneLabel?: string;
onClose: () => void;
}): React.ReactNode {
const status = statusOf(node);
const tone = FLOW_TONE[status];
const props = node.props ?? {};
const hasProps = Object.keys(props).length > 0;
const log = node.log ?? [];
return (
<Surface className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{node.label}</span>
{node.kind ? <Tag tone="neutral">{node.kind}</Tag> : null}
<Tag tone={tone.tag}>{tone.label}</Tag>
{laneLabel ? <Tag tone="accent">泳道 · {laneLabel}</Tag> : null}
<button
onClick={onClose}
aria-label="关闭详情"
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-lo)] transition-colors hover:bg-[var(--mu-surface-4)]"
>
<Icon name="close" size={16} />
</button>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--mu-text-lo)]">
{node.startedAt ? <span>开始 {fmtClock(node.startedAt)}</span> : null}
{node.endedAt ? <span>结束 {fmtClock(node.endedAt)}</span> : null}
<span className="font-mono">{node.id}</span>
</div>
{hasProps ? (
<div className="space-y-1">
<h4 className="text-xs font-medium text-[var(--mu-text-lo)]">参数 / 结果</h4>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-text-mid)]">
{JSON.stringify(props, null, 2)}
</pre>
</div>
) : null}
<LogList node={node} log={log} />
{!hasProps && log.length === 0 ? <Notice>该节点暂无详细日志。</Notice> : null}
</Surface>
);
}
/** LogList renders the node's accumulated span events, newest last, and is
* explicit about why an empty list is empty instead of pretending to have
* content. */
function LogList({
node,
log,
}: {
node: FlowNode;
log: NonNullable<FlowNode["log"]>;
}): React.ReactNode {
const running = statusOf(node) === "running";
if (log.length === 0) {
return (
<div className="rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] px-3 py-2 text-xs text-[var(--mu-text-lo)]">
实时日志:暂无事件 ——{" "}
{running ? "该 span 仍在运行,事件在 span 结束后随帧回填。" : "该 span 未记录事件。"}
</div>
);
}
return (
<div className="space-y-1.5">
<h4 className="flex items-center gap-2 text-xs font-medium text-[var(--mu-text-lo)]">
实时日志(span 事件累积)
<span className="font-mono">{log.length}</span>
{running ? <span className="mu-blink text-[var(--mu-accent)]">● 运行中</span> : null}
</h4>
<div className="max-h-64 space-y-1.5 overflow-auto">
{log.map((ev, i) => (
<div
key={`${ev.time}-${ev.type}-${i}`}
className="flex flex-wrap items-start gap-2 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)]/60 px-3 py-2 text-xs"
>
<span className="shrink-0 font-mono text-[var(--mu-text-lo)]">{fmtClock(ev.time)}</span>
<span className="font-medium text-[var(--mu-text-mid)]">{ev.type || "event"}</span>
{ev.props ? (
<code className="min-w-0 break-all font-mono text-[var(--mu-text-lo)]">
{JSON.stringify(ev.props)}
</code>
) : null}
</div>
))}
</div>
</div>
);
}
/** fmtClock renders an RFC3339 stamp as a locale time, or "—" when absent/invalid. */
function fmtClock(value?: string): string {
if (!value) return "—";
const t = Date.parse(value);
return Number.isNaN(t) ? "—" : new Date(t).toLocaleTimeString();
}
@@ -0,0 +1,312 @@
// Logic test for the P2 lane layer of the flow page (design/19 §2): the pure
// swimlane grouping (`groupFlowLanes`) and the lane-banded layout (`layoutFlow`)
// that the SVG renderer draws. The project has no frontend test runner and no
// jsdom, so this is a dependency-free plain-node check that drives the real
// functions and throws on the first failure.
//
// Run it with the esbuild that already ships with vite (no new dependency, no
// package.json change):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/lanes.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-flow-lanes.test.mjs \
// && node /tmp/cyrene-flow-lanes.test.mjs
//
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
// imports nothing from node:*.
import { groupFlowLanes } from "./lanes";
import { GAP_X, NODE_W, PAD, layoutFlow } from "./layout";
import type { FlowEdge, FlowGraph, FlowNode } from "../../../lib/types";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(g === w, `${what} — got ${g}, want ${w}`);
}
function node(id: string, kind: string, status?: FlowNode["status"]): FlowNode {
return { id, label: id, kind, status };
}
function graph(nodes: FlowNode[], edges: FlowEdge[]): FlowGraph {
return { id: "web-1", nodes, edges, sessionId: "web" };
}
/** The P1 trunk: input → llm → tool → output, no sub-agent in flight. */
function trunkOnly(): FlowGraph {
return graph(
[
node("input", "input", "done"),
node("llm1", "llm", "done"),
node("tool1", "tool", "done"),
node("output", "output", "done"),
],
[
{ from: "input", to: "llm1" },
{ from: "llm1", to: "tool1" },
{ from: "tool1", to: "output" },
],
);
}
/** Two parallel sub-agent branches hanging off the same trunk node, matching the
* edge shape the Go builder produces (graph_lane_test.go): each lane chains from
* its own root, and the trunk resumes from the spawn node, not from a branch. */
function parallel(): FlowGraph {
return graph(
[
node("input", "input", "done"),
node("t1", "tool", "done"),
node("subA", "subagent", "running"),
node("llmA", "llm", "running"),
node("toolA", "tool", "done"),
node("subB", "subagent", "done"),
node("llmB", "llm", "done"),
node("llm2", "llm", "done"),
node("output", "output", "done"),
],
[
{ from: "input", to: "t1" },
{ from: "t1", to: "subA" },
{ from: "t1", to: "subB" },
{ from: "subA", to: "llmA" },
{ from: "llmA", to: "toolA" },
{ from: "subB", to: "llmB" },
{ from: "t1", to: "llm2" },
{ from: "llm2", to: "output" },
],
);
}
// --- trunk only: no lane, columns unchanged (no P1 regression) ---------------
{
const g = trunkOnly();
const grouping = groupFlowLanes(g);
same(grouping.lanes.length, 0, "trunk graph has no lane");
ok(grouping.bandOf.get("llm1") === undefined, "trunk node has no band");
const { nodes, bands } = layoutFlow(g);
const at = new Map(nodes.map((p) => [p.node.id, p]));
same(bands.length, 1, "trunk graph has exactly one band");
same(bands[0].framed, false, "the trunk band is not framed");
same(at.get("input")!.x, PAD, "input sits in the first column");
ok(
at.get("output")!.x > at.get("tool1")!.x &&
at.get("tool1")!.x > at.get("llm1")!.x &&
at.get("llm1")!.x > at.get("input")!.x,
"trunk columns grow left to right",
);
same(
[at.get("input")!.y, at.get("llm1")!.y, at.get("tool1")!.y, at.get("output")!.y],
[PAD, PAD, PAD, PAD],
"a single-row trunk keeps one row",
);
// P1 pixel parity: with no lane in play the geometry is exactly the old
// longest-path column grid, so P2 cannot regress the trunk view.
same(
nodes.map((p) => [p.node.id, p.x, p.y]),
[
["input", PAD, PAD],
["llm1", PAD + (NODE_W + GAP_X), PAD],
["tool1", PAD + 2 * (NODE_W + GAP_X), PAD],
["output", PAD + 3 * (NODE_W + GAP_X), PAD],
],
"trunk-only layout matches the P1 column grid",
);
}
// --- parallel branches: two lanes, each holding its own step chain -----------
{
const g = parallel();
const grouping = groupFlowLanes(g);
same(
grouping.lanes.map((l) => l.id),
["subA", "subB"],
"one lane per sub-agent node",
);
same(grouping.lanes.map((l) => l.depth), [0, 0], "both lanes are top-level");
same(
grouping.lanes.map((l) => l.members.map((m) => m.id)),
[
["llmA", "toolA"],
["llmB"],
],
"lane members follow the branch, in graph order",
);
same(
[
grouping.bandOf.get("llmA"),
grouping.bandOf.get("toolA"),
grouping.bandOf.get("llmB"),
grouping.bandOf.get("llm2"),
grouping.bandOf.get("subA"),
],
["subA", "subA", "subB", undefined, undefined],
"band membership: sub-agent roots stay on the trunk",
);
const laneA = grouping.lanes[0];
same(
[laneA.status, laneA.total, laneA.running, laneA.done, laneA.failed],
["running", 2, 1, 1, 0],
"lane status comes from the root when reported",
);
same(laneA.root.status, "running", "lane A root keeps its own status");
// Aggregation only kicks in when the root reports nothing.
const rootless = groupFlowLanes({
...g,
nodes: g.nodes.map((n) => (n.id === "subA" ? { ...n, status: undefined } : n)),
}).lanes[0];
same(rootless.status, "running", "rootless lane aggregates a running member");
const failed = groupFlowLanes({
...g,
nodes: g.nodes.map((n) =>
n.id === "subA"
? { ...n, status: undefined }
: n.id === "toolA"
? { ...n, status: "error" as const }
: n,
),
}).lanes[0];
same(failed.status, "error", "any failed member turns the lane red");
const { nodes, bands, edges } = layoutFlow(g);
const at = new Map(nodes.map((p) => [p.node.id, p]));
const framed = bands.filter((b) => b.framed);
same(framed.length, 2, "each lane gets its own framed band");
// Lane steps start one column right of their sub-agent root.
same(
at.get("llmA")!.x - at.get("subA")!.x,
NODE_W + GAP_X,
"lane A's first step starts one column right of its root",
);
same(
at.get("toolA")!.x,
at.get("llmA")!.x + NODE_W + GAP_X,
"lane A chains toolA after llmA",
);
same(
at.get("llmB")!.x - at.get("subB")!.x,
NODE_W + GAP_X,
"lane B's step starts one column right of its root",
);
// Lanes are stacked below the trunk and never share rows with each other.
const trunkBottom = PAD + 52;
ok(framed[0].y >= trunkBottom, "lane A sits below the trunk");
ok(framed[1].y >= framed[0].y + framed[0].height, "lane B sits below lane A");
const rowA = at.get("llmA")!.y;
const rowB = at.get("llmB")!.y;
ok(rowA !== rowB, "parallel lanes do not share a row");
ok(at.get("llm2")!.y < framed[0].y, "the trunk continuation stays in the trunk band");
// No two cards overlap.
const seen = new Set(nodes.map((p) => `${p.x},${p.y}`));
same(seen.size, nodes.length, "every node has its own slot");
// Edges into a lane are marked so the renderer can dash them.
const byKey = new Map(edges.map((e) => [`${e.edge.from}>${e.edge.to}`, e.lane]));
same(byKey.get("subA>llmA"), "subA", "lane entry edge is a lane edge");
same(byKey.get("llmA>toolA"), "subA", "intra-lane edge is a lane edge");
same(byKey.get("subB>llm2"), undefined, "trunk continuation is a trunk edge");
same(byKey.get("t1>llm2"), undefined, "the trunk resumes from the spawn node");
}
// --- nested sub-agent: a lane inside a lane ----------------------------------
{
const nested = graph(
[
node("input", "input", "done"),
node("subA", "subagent", "running"),
node("llmA", "llm", "done"),
node("subN", "subagent", "running"),
node("llmN", "llm", "running"),
],
[
{ from: "input", to: "subA" },
{ from: "subA", to: "llmA" },
{ from: "llmA", to: "subN" },
{ from: "subN", to: "llmN" },
],
);
const grouping = groupFlowLanes(nested);
same(
grouping.lanes.map((l) => [l.id, l.depth, l.parentLaneId]),
[
["subA", 0, undefined],
["subN", 1, "subA"],
],
"a nested sub-agent is its own lane one level deeper",
);
same(grouping.bandOf.get("subN"), "subA", "the nested root is drawn in its parent lane");
same(grouping.bandOf.get("llmN"), "subN", "the nested root's steps belong to the nested lane");
const { bands } = layoutFlow(nested);
const framed = bands.filter((b) => b.framed);
same(framed.length, 2, "nested lanes each get a frame");
ok(framed[1].y > framed[0].y, "the nested lane is drawn below its parent lane");
}
// --- degenerate input must not throw -----------------------------------------
{
const empty: FlowGraph = { id: "g", nodes: [], edges: [] };
const emptyLayout = layoutFlow(empty);
same(
[emptyLayout.nodes.length, emptyLayout.bands.length, emptyLayout.lanes.length],
[0, 0, 0],
"empty graph",
);
same(
[emptyLayout.width, emptyLayout.height],
[PAD * 2, PAD * 2],
"empty graph keeps the padding box",
);
const messy = graph(
[node("a", "llm", "done"), node("b", "llm", "done")],
[
{ from: "a", to: "a" },
{ from: "a", to: "ghost" },
{ from: "a", to: "b" },
{ from: "b", to: "a" },
],
);
const grouping = groupFlowLanes(messy);
same(grouping.edges.length, 2, "self-loops and dangling edges are dropped");
const messyLayout = layoutFlow(messy);
same(messyLayout.nodes.length, 2, "layout terminates on a cyclic graph");
// A wide layer must not push two cards onto the same slot.
const wide = graph(
[node("r", "input", "done"), node("c1", "llm"), node("c2", "llm"), node("c3", "llm")],
[
{ from: "r", to: "c1" },
{ from: "r", to: "c2" },
{ from: "r", to: "c3" },
],
);
const wideLayout = layoutFlow(wide);
const slots = new Set(wideLayout.nodes.map((p) => `${p.x},${p.y}`));
same(slots.size, 4, "fan-out siblings get distinct rows");
// A malformed frame with a duplicated id must not draw the same card twice.
const dup = graph([node("a", "llm", "done"), node("a", "llm", "done")], []);
same(layoutFlow(dup).nodes.length, 1, "a duplicated node id is drawn once");
}
console.log(`flow/lanes.test.ts OK — ${checks} checks`);
@@ -0,0 +1,166 @@
import type {
AgentStatus,
FlowEdge,
FlowGraph,
FlowNode,
} from "../../../lib/types";
import { statusOf } from "./tone";
/** Swimlane grouping for the live flow graph (design/19 §2): the main trunk
* (输入→思考→工具→输出) stays one band, and every sub-agent node opens its own
* band holding the step chain of that branch, so parallel branches never
* interleave visually. Membership is derived from the edges alone: a node
* belongs to the nearest sub-agent node upstream of it (the builder wires each
* lane's steps behind its sub-agent root, see internal/runtime/trace/graph.go).
*
* Everything here is a pure function over a FlowGraph — no React, no store — so
* it is unit-testable on a plain node run (flow/lanes.test.ts). */
/** The node kind that opens a lane. */
export const SUBAGENT_KIND = "subagent";
export interface FlowLane {
/** The sub-agent root node's id. */
id: string;
root: FlowNode;
/** The nodes drawn in this lane's band, in graph order. A nested sub-agent
* root is a member of its parent lane AND a lane of its own. */
members: FlowNode[];
/** The enclosing lane id when this sub-agent was spawned inside another. */
parentLaneId?: string;
/** Nesting depth: 0 = spawned by the trunk. */
depth: number;
/** The root's own outcome when reported, else aggregated from the members. */
status: AgentStatus;
total: number;
running: number;
done: number;
failed: number;
}
export interface FlowGrouping {
byId: Map<string, FlowNode>;
/** The graph's nodes, graph order, first occurrence per id (duplicated ids can
* only come from a malformed frame; they must not draw twice). */
nodes: FlowNode[];
/** node id → the band it is drawn in (a lane id, or undefined = trunk). */
bandOf: Map<string, string | undefined>;
/** Every lane, ordered by nesting depth then graph order. */
lanes: FlowLane[];
/** Valid edges (self-loops and dangling endpoints dropped). */
edges: FlowEdge[];
}
/** groupFlowLanes partitions a graph into the trunk band plus one band per
* sub-agent node. */
export function groupFlowLanes(graph: FlowGraph): FlowGrouping {
const byId = new Map<string, FlowNode>();
const index = new Map<string, number>();
const nodes: FlowNode[] = [];
graph.nodes.forEach((n, i) => {
if (!byId.has(n.id)) {
byId.set(n.id, n);
index.set(n.id, i);
nodes.push(n);
}
});
const preds = new Map<string, string[]>();
byId.forEach((_, id) => preds.set(id, []));
const edges: FlowEdge[] = [];
graph.edges.forEach((e) => {
if (e.from === e.to) return;
if (!byId.has(e.from) || !byId.has(e.to)) return;
if (preds.get(e.to)!.includes(e.from)) return;
preds.get(e.to)!.push(e.from);
edges.push(e);
});
const laneAncestor = (id: string): string | undefined =>
nearestSubagent(id, preds, byId, index);
const bandOf = new Map<string, string | undefined>();
byId.forEach((_, id) => bandOf.set(id, laneAncestor(id)));
const roots = nodes.filter((n) => n.kind === SUBAGENT_KIND);
const depthMemo = new Map<string, number>();
const laneDepth = (id: string): number => {
const cached = depthMemo.get(id);
if (cached !== undefined) return cached;
const parent = bandOf.get(id);
const d = parent === undefined || parent === id ? 0 : laneDepth(parent) + 1;
depthMemo.set(id, d);
return d;
};
const lanes: FlowLane[] = roots
.map((root) => {
const members = nodes.filter((n) => n.id !== root.id && bandOf.get(n.id) === root.id);
let running = 0;
let done = 0;
let failed = 0;
members.forEach((m) => {
const s = statusOf(m);
if (s === "running") running += 1;
else if (s === "done") done += 1;
else if (s === "error") failed += 1;
});
const parentLaneId = bandOf.get(root.id);
return {
id: root.id,
root,
members,
parentLaneId,
depth: laneDepth(root.id),
status: laneStatus(root, members.length, running, failed),
total: members.length,
running,
done,
failed,
};
})
.sort((a, b) => a.depth - b.depth || index.get(a.id)! - index.get(b.id)!);
return { byId, nodes, bandOf, lanes, edges };
}
/** nearestSubagent walks upstream from id and returns the closest sub-agent node
* (BFS by hop count, ties broken by graph order so the result is stable). */
function nearestSubagent(
id: string,
preds: Map<string, string[]>,
byId: Map<string, FlowNode>,
index: Map<string, number>,
): string | undefined {
const seen = new Set<string>([id]);
let frontier = preds.get(id) ?? [];
while (frontier.length > 0) {
const layer = frontier.filter((p) => !seen.has(p));
if (layer.length === 0) return undefined;
layer.forEach((p) => seen.add(p));
const hits = layer.filter((p) => byId.get(p)?.kind === SUBAGENT_KIND);
if (hits.length > 0) {
return hits.sort((a, b) => index.get(a)! - index.get(b)!)[0];
}
const next: string[] = [];
layer.forEach((p) => (preds.get(p) ?? []).forEach((q) => next.push(q)));
frontier = next;
}
return undefined;
}
/** laneStatus prefers the sub-agent's own reported status; when the root has no
* status yet it aggregates the members (any failure wins, then anything still
* running, then all-done) — never inventing an outcome. */
function laneStatus(
root: FlowNode,
total: number,
running: number,
failed: number,
): AgentStatus {
if (root.status) return root.status;
if (failed > 0) return "error";
if (running > 0) return "running";
if (total > 0) return "done";
return "idle";
}
@@ -0,0 +1,242 @@
import type { AgentStatus, FlowEdge, FlowGraph, FlowNode } from "../../../lib/types";
import { groupFlowLanes, type FlowGrouping, type FlowLane } from "./lanes";
/** Lane-aware left-to-right layout for the flow graph (design/19 §2). The trunk
* keeps the P1 "longest path depth" columns; every sub-agent lane gets its own
* horizontal band below the trunk, its steps starting one column right of the
* sub-agent root and chaining rightwards. Bands never share rows, so parallel
* branches cannot overlap — the canvas just grows (and scrolls) instead. */
export const NODE_W = 148;
export const NODE_H = 52;
export const GAP_X = 64;
export const GAP_Y = 30;
export const PAD = 20;
/** Vertical gap between two bands. */
export const BAND_GAP = 26;
/** Padding between a lane frame and the nodes it holds. */
export const FRAME_PAD = 10;
/** Height reserved inside a lane frame for its label row. */
export const LANE_HEADER = 22;
/** Minimum columns a lane frame spans, so an idle lane is still readable. */
const MIN_LANE_COLS = 1;
export interface PlacedNode {
node: FlowNode;
x: number;
y: number;
}
export interface PlacedBand {
/** The lane id, or undefined for the trunk band. */
id?: string;
label: string;
status: AgentStatus;
/** Frame rect; for the trunk band this is the content area (not framed). */
x: number;
y: number;
width: number;
height: number;
framed: boolean;
}
export interface PlacedEdge {
edge: FlowEdge;
/** The lane the edge runs into/inside, when it is not a trunk edge. */
lane?: string;
}
export interface FlowLayout {
nodes: PlacedNode[];
edges: PlacedEdge[];
bands: PlacedBand[];
lanes: FlowLane[];
width: number;
height: number;
}
interface BandPlan {
id?: string;
lane?: FlowLane;
members: FlowNode[];
/** node id → column offset inside the band (longest path over the band's own edges). */
depth: Map<string, number>;
/** Absolute first column of the band. */
originCol: number;
rows: number;
fromCol: number;
toCol: number;
}
/** layoutFlow computes the lane-banded geometry of a graph. */
export function layoutFlow(graph: FlowGraph): FlowLayout {
const grouping = groupFlowLanes(graph);
if (grouping.byId.size === 0) {
return { nodes: [], edges: [], bands: [], lanes: [], width: PAD * 2, height: PAD * 2 };
}
const index = orderIndex(grouping.nodes);
const plans = planBands(grouping);
const { nodes, bands, width, height } = placeBands(plans, index);
const placed = new Set(nodes.map((p) => p.node.id));
const edges: PlacedEdge[] = grouping.edges
.filter((e) => placed.has(e.from) && placed.has(e.to))
.map((edge) => ({ edge, lane: grouping.bandOf.get(edge.to) }));
return { nodes, edges, bands, lanes: grouping.lanes, width, height };
}
/** orderIndex maps every node id to its graph-order position. */
function orderIndex(nodes: FlowNode[]): Map<string, number> {
const index = new Map<string, number>();
nodes.forEach((n, i) => index.set(n.id, i));
return index;
}
/** planBands builds one plan per band, trunk first: a lane's origin column is
* derived from its root's column, so the enclosing band must be planned before
* it — lane order (by nesting depth) guarantees exactly that. */
function planBands(grouping: FlowGrouping): BandPlan[] {
const { bandOf, lanes, nodes } = grouping;
const trunk: BandPlan = {
members: nodes.filter((n) => bandOf.get(n.id) === undefined),
depth: new Map(),
originCol: 0,
rows: 1,
fromCol: 0,
toCol: 0,
};
const plans: BandPlan[] = [
trunk,
...lanes.map((lane) => ({
id: lane.id,
lane,
members: lane.members,
depth: new Map<string, number>(),
originCol: 0,
rows: 1,
fromCol: 0,
toCol: 0,
})),
];
/** colOf is the absolute column a node is drawn in. */
const colOf = (id: string): number => {
const band = plans.find((p) => p.depth.has(id));
return band ? band.originCol + band.depth.get(id)! : 0;
};
plans.forEach((plan) => {
if (plan.id !== undefined && plan.lane) {
plan.originCol = colOf(plan.lane.root.id) + 1;
}
computeBandColumns(plan, grouping.edges);
const cols = plan.members.map((n) => plan.originCol + plan.depth.get(n.id)!);
plan.fromCol = cols.length > 0 ? Math.min(...cols) : plan.originCol;
plan.toCol = cols.length > 0 ? Math.max(...cols) : plan.fromCol + MIN_LANE_COLS;
});
return plans;
}
/** placeBands turns the plans into pixel geometry: bands stack top-down (lanes
* framed, with a header row for their label) and every node gets its own slot. */
function placeBands(
plans: BandPlan[],
index: Map<string, number>,
): { nodes: PlacedNode[]; bands: PlacedBand[]; width: number; height: number } {
const pos = new Map<string, PlacedNode>();
const bands: PlacedBand[] = [];
/** Top of the next band (its frame top, header included). */
let limitY = PAD;
let contentBottom = PAD;
let maxCol = 0;
plans.forEach((plan) => {
const framed = plan.id !== undefined;
const frameY = limitY;
const contentY = framed ? frameY + LANE_HEADER + FRAME_PAD : frameY;
const bandHeight = rowsHeight(plan.rows);
const frameHeight = framed ? bandHeight + LANE_HEADER + FRAME_PAD * 2 : bandHeight;
const byCol = new Map<number, FlowNode[]>();
plan.members.forEach((n) => {
const col = plan.originCol + plan.depth.get(n.id)!;
const arr = byCol.get(col) ?? [];
arr.push(n);
byCol.set(col, arr);
maxCol = Math.max(maxCol, col);
});
byCol.forEach((arr, col) => {
arr
.sort((a, b) => index.get(a.id)! - index.get(b.id)!)
.forEach((n, row) => {
pos.set(n.id, {
node: n,
x: PAD + col * (NODE_W + GAP_X),
y: contentY + row * (NODE_H + GAP_Y),
});
});
});
bands.push({
id: plan.id,
label: plan.lane ? plan.lane.root.label : "主干",
status: plan.lane ? plan.lane.status : "idle",
x: PAD + plan.fromCol * (NODE_W + GAP_X) - (framed ? FRAME_PAD : 0),
y: frameY,
width: (plan.toCol - plan.fromCol + 1) * (NODE_W + GAP_X) - GAP_X + (framed ? FRAME_PAD * 2 : 0),
height: frameHeight,
framed,
});
contentBottom = frameY + frameHeight;
limitY = contentBottom + BAND_GAP;
});
return {
nodes: [...pos.values()].sort((a, b) => index.get(a.node.id)! - index.get(b.node.id)!),
bands,
width: PAD * 2 + (maxCol + 1) * NODE_W + maxCol * GAP_X,
height: Math.max(contentBottom + PAD, PAD * 2),
};
}
/** computeBandColumns fills a band's per-node depth (longest path inside the
* band) and row count. Edges leaving the band are ignored: a lane's entry edge
* comes from its root, which is drawn in the enclosing band. */
function computeBandColumns(plan: BandPlan, edges: FlowEdge[]): void {
const memberIds = new Set(plan.members.map((n) => n.id));
const preds = new Map<string, string[]>();
memberIds.forEach((id) => preds.set(id, []));
edges.forEach((e) => {
if (memberIds.has(e.from) && memberIds.has(e.to)) preds.get(e.to)!.push(e.from);
});
const memo = new Map<string, number>();
const visiting = new Set<string>();
const depth = (id: string): number => {
const cached = memo.get(id);
if (cached !== undefined) return cached;
if (visiting.has(id)) return 0;
visiting.add(id);
let d = 0;
for (const p of preds.get(id) ?? []) d = Math.max(d, depth(p) + 1);
visiting.delete(id);
memo.set(id, d);
return d;
};
const perCol = new Map<number, number>();
plan.members.forEach((n) => {
const d = depth(n.id);
plan.depth.set(n.id, d);
const col = plan.originCol + d;
perCol.set(col, (perCol.get(col) ?? 0) + 1);
});
plan.rows = perCol.size > 0 ? Math.max(...perCol.values()) : 1;
}
/** rowsHeight is the pixel height of rows stacked node cards. */
function rowsHeight(rows: number): number {
return rows * NODE_H + (rows - 1) * GAP_Y;
}
@@ -0,0 +1,59 @@
import type { AgentStatus, FlowNode } from "../../../lib/types";
import type { TagTone } from "../../../components/primitives";
/** Miuix status presentation for flow nodes (design/19 §2: 节点闪烁=运行中、
* ok=成功、err=失败). The stroke/fill colors are Miuix tokens so the graph
* follows the panel theme (light/dark) without hard-coded colors. */
export interface FlowTone {
stroke: string;
fill: string;
label: string;
tag: TagTone;
}
export const FLOW_TONE: Record<AgentStatus, FlowTone> = {
idle: {
stroke: "var(--mu-surface-5)",
fill: "color-mix(in srgb, var(--mu-surface-5) 18%, transparent)",
label: "空闲",
tag: "neutral",
},
running: {
stroke: "var(--mu-accent)",
fill: "color-mix(in srgb, var(--mu-accent) 18%, transparent)",
label: "运行中",
tag: "accent",
},
blocked: {
stroke: "var(--mu-warn)",
fill: "color-mix(in srgb, var(--mu-warn) 18%, transparent)",
label: "阻塞",
tag: "warn",
},
done: {
stroke: "var(--mu-ok)",
fill: "color-mix(in srgb, var(--mu-ok) 18%, transparent)",
label: "完成",
tag: "ok",
},
error: {
stroke: "var(--mu-err)",
fill: "color-mix(in srgb, var(--mu-err) 18%, transparent)",
label: "错误",
tag: "err",
},
};
/** Legend / aggregate order, coldest to most alarming. */
export const STATUS_ORDER: ReadonlyArray<AgentStatus> = [
"idle",
"running",
"blocked",
"done",
"error",
];
/** statusOf narrows a node's optional status to a concrete AgentStatus. */
export function statusOf(node: FlowNode): AgentStatus {
return node.status ?? "idle";
}