import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { hierarchy, tree, type HierarchyPointNode } from "d3-hierarchy";
import type { AgentRuntime, SessionView, Topology, TopologyNode } from "../types";
import { useHive } from "../store";
import { viewAgent } from "../store/raw";
import { usePanZoom } from "../hooks/usePanZoom";
import { fmtNum, clip } from "../lib/format";
import { ctxColor, modelTag, resolveDialLevels, statusKey, thinkBars, thinkLevel, thinkName, tokPerSec } from "../lib/agents";
const NODE_H = 92;
// Card width adapts to content so full agent names + model tags fit without
// cropping. Computed per-tree from the widest name+badge, clamped to a range.
const NODE_W_MIN = 188;
const NODE_W_MAX = 340;
// Width the model pill needs for its tag text (≈5.4px/char at 8px mono + pad).
function tagWidth(tag: string): number {
return Math.min(120, Math.max(22, tag.length * 5.4 + 12));
}
// Width a node needs to show its full id + model badge on row 1:
// left pad(12) + dot(~15) + id text + gap(8) + badge + right pad(12)
function nodeWidthFor(name: string, tag: string): number {
const idW = name.length * 6.7;
return 12 + 15 + idW + 8 + tagWidth(tag) + 12;
}
// The node's enforcement contract, rendered as a native SVG
tooltip on
// hover (Phase 6.1). Answers "what may this agent touch / can it commit / which
// gates does this planner own". Absent fields are omitted so the tooltip only
// shows what the config actually declares; returns "" when nothing is known.
function enforcementSummary(node: TopologyNode): string {
const lines: string[] = [];
const kind = node.agentType || node.role;
if (kind) lines.push(`type: ${kind}`);
if (node.commit) lines.push("commit: yes");
if (node.domain?.length) lines.push(`domains: ${node.domain.join(", ")}`);
if (node.stages?.length) lines.push(`plan gates: ${node.stages.join(", ")}`);
if (node.consultWhen) lines.push(`consult when: ${node.consultWhen}`);
if (node.routingTags?.length) lines.push(`routing: ${node.routingTags.join(", ")}`);
if (node.responsibilities) lines.push(`responsibilities:\n${node.responsibilities}`);
return lines.join("\n");
}
type TopologyKind = "active" | "hive" | "planning";
function pickTopology(sess: SessionView | undefined, kind: TopologyKind): Topology | undefined {
if (!sess) return undefined;
if (kind === "active") {
// A live session carries the resolved active tree on `sess.topology`. A
// source built by the version modal / replay instead sets `topologies.active`
// + the per-team trees but no `sess.topology`, so fall back to the active
// team's tree there (else the modal always renders empty).
if (sess.topology) return sess.topology;
const active = sess.topologies?.active;
if (active === "hive") return sess.topologies?.hive;
if (active === "planning") return sess.topologies?.planning;
return undefined;
}
const teamTopo = kind === "hive" ? sess.topologies?.hive : sess.topologies?.planning;
if (teamTopo) return teamTopo;
if (!sess.topologies && kind === "hive") return sess.topology;
return sess.topologies?.active === kind ? sess.topology : undefined;
}
function descendantNames(node: TopologyNode | undefined, into: Set) {
for (const c of node?.children || []) { into.add(c.name); descendantNames(c, into); }
}
function rootOf(t: Topology | undefined): TopologyNode | null {
if (!t) return null;
const agents: TopologyNode[] = t.agents || [];
if (!t.orchestrator) {
if (agents.length === 1) return agents[0];
// A fabricated grouping root (no real orchestrator configured). Marked
// synthetic so the renderer draws it as a non-interactive group header —
// no CTX/TOK-S stats, not clickable (E4).
return { name: "Hive", role: "orchestrator", synthetic: true, children: dedupeByName(agents) } as TopologyNode & { synthetic?: boolean };
}
const orchChildren = t.orchestrator.children || [];
const placed = new Set(orchChildren.map((c: TopologyNode) => c.name));
descendantNames({ name: "", children: orchChildren }, placed);
for (const a of agents) descendantNames(a, placed);
const extraRoots = agents.filter((a) => !placed.has(a.name));
return { ...t.orchestrator, children: dedupeByName([...orchChildren, ...extraRoots]) };
}
function dedupeByName(nodes: TopologyNode[]): TopologyNode[] {
const seen = new Set();
const out: TopologyNode[] = [];
for (const n of nodes) { if (!seen.has(n.name)) { seen.add(n.name); out.push(n); } }
return out;
}
// elbow path between parent bottom and child top
function edgePath(l: { source: HierarchyPointNode; target: HierarchyPointNode; }, ox: number) {
const x1 = l.source.x + ox, y1 = l.source.y + NODE_H, x2 = l.target.x + ox, y2 = l.target.y;
const my = y1 + (y2 - y1) / 2;
return `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`;
}
// A session-like object TopologyGraph can render. `currentSession` from the
// store is one; a replay slice (K5) or a versioned-topology modal (K2) can build
// another. When statusMode is "none" every node reads idle (historical view).
export interface TopoSource {
session_id?: string;
topology?: Topology;
topologies?: SessionView["topologies"];
agents?: Map;
}
// live — subscribe to the live event-status store (default).
// snapshot — use only the statuses on the passed source.agents (replay, K5): the
// source is the same session id as a live one, so we must NOT read the
// live event-status store or current statuses would leak in.
// none — every node reads idle (historical version view, K2 modal).
export type TopoStatusMode = "live" | "snapshot" | "none";
export default function TopologyGraph(props: { kind?: TopologyKind; source?: TopoSource; statusMode?: TopoStatusMode }) {
const liveSession = useHive((s) => s.currentSession);
// Prefer an explicit source (replay/modal); fall back to the live session.
const session = (props.source ?? liveSession) as (SessionView & TopoSource) | undefined;
const statusMode = props.statusMode ?? "live";
const [collapsed, setCollapsed] = useState>(new Set());
const svgRef = useRef(null);
const { view, setView, grabbing, handlers } = usePanZoom(svgRef);
const kind = props.kind || "active";
const topology = useMemo(() => pickTopology(session, kind), [session, kind]);
// Structural signature: the layout depends ONLY on the tree shape + which
// subtrees are collapsed — never on status/tokens/cost. Keying the layout memo
// on this means a live status tick does NOT recompute d3 positions.
const structureKey = useMemo(() => {
const root = rootOf(topology);
if (!root) return "∅";
const sig: string[] = [];
const walk = (n: TopologyNode, depth: number) => {
// include model so card width (which depends on the model tag) recomputes
sig.push(depth + ":" + n.name + "@" + (n.model || "") + (collapsed.has(n.name) ? "*" : ""));
if (!collapsed.has(n.name)) for (const c of n.children || []) walk(c, depth + 1);
};
walk(root, 0);
return sig.join("|");
}, [topology, collapsed]);
const layout = useMemo(() => {
const root = rootOf(topology);
if (!root) return null;
const col = collapsed;
const visited = new Set([root.name]);
const h = hierarchy(root, (d) => {
if (col.has(d.name)) return null;
return (d.children || []).filter((c) => { if (visited.has(c.name)) return false; visited.add(c.name); return true; });
});
// First pass over the visible nodes to size all cards to the widest content,
// so no name/model gets cropped and every card stays a uniform width.
const probe = hierarchy(root, (d) => {
if (col.has(d.name)) return null;
return (d.children || []);
});
let needed = NODE_W_MIN;
for (const n of probe.descendants()) {
const d = n.data;
needed = Math.max(needed, nodeWidthFor(d.name, modelTag(d.model)));
}
const nodeW = Math.min(NODE_W_MAX, Math.ceil(needed));
const layoutTree = tree().nodeSize([nodeW + 44, NODE_H + 62]);
const laidOut = layoutTree(h);
const nodes = laidOut.descendants();
const links = laidOut.links();
const xs = nodes.map((n) => n.x);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const maxY = Math.max(...nodes.map((n) => n.y));
return {
nodes, links, nodeW,
width: (maxX - minX) + nodeW + 80,
height: maxY + NODE_H + 60,
ox: -minX + nodeW / 2 + 24,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [structureKey]);
const agentsMap = session?.agents || new Map();
// When viewing a configured but inactive team, do not project the live
// event-status overlay onto it.
const inactiveTeam = !!(session?.topologies && kind !== "active" && session.topologies.active !== kind);
const fit = useCallback((attempt = 0) => {
const lo = layout; if (!lo || !svgRef.current) return;
const box = svgRef.current.getBoundingClientRect();
if ((box.width < 10 || box.height < 10) && attempt < 20) {
requestAnimationFrame(() => fit(attempt + 1));
return;
}
const k = Math.max(0.35, Math.min(1.1, (box.width - 48) / lo.width, (box.height - 48) / lo.height));
setView({ k, x: (box.width - lo.width * k) / 2, y: Math.max(18, (box.height - lo.height * k) / 2) });
}, [layout, setView]);
// Auto-fit on data change (session/team/agent set), not on collapse.
const fitKey = useMemo(() => {
const names = layout ? layout.nodes.map((n) => n.data.name).join(",") : "";
return (session?.session_id || "") + "|" + kind + "|" + names;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.session_id, kind, structureKey]);
useEffect(() => { fit(); }, [fitKey, fit]);
useEffect(() => {
const svg = svgRef.current;
if (!svg || !("ResizeObserver" in window)) return;
let last = 0;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width || 0;
if (Math.abs(w - last) > 8) { last = w; fit(); }
});
ro.observe(svg);
return () => ro.disconnect();
}, [fit]);
function toggle(name: string, hasChildren: boolean) {
if (!hasChildren) return;
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name); else next.add(name);
return next;
});
}
const emptyText = kind === "planning"
? "No planning topology yet. Add a planning team and start a hive session."
: "No topology yet. Start a hive session so it emits session_start.";
return (
{!layout ?
{emptyText}
: (
)}
);
}
// Each edge/node subscribes to its own agent status so a status change repaints
// just that element — positions (from the memoized layout) stay untouched.
// statusMode: "live" reads the live event-status store; "snapshot" uses only the
// passed snapStatus (replay/historical, so live status can't leak); "none" idle.
function useStatus(sessionId: string, name: string, inactiveTeam: boolean, snapStatus?: string, statusMode: TopoStatusMode = "live"): string {
const evStatus = useHive((s) => (statusMode === "live" ? s.eventStatus.get(sessionId)?.get(name) : undefined));
if (inactiveTeam || statusMode === "none") return "idle";
if (statusMode === "snapshot") return snapStatus || "idle";
return evStatus || snapStatus || "idle";
}
function EdgeLine(props: { link: any; ox: number; sessionId: string; inactiveTeam: boolean; snapStatus?: string; statusMode?: TopoStatusMode }) {
const status = useStatus(props.sessionId, props.link.target.data.name, props.inactiveTeam, props.snapStatus, props.statusMode);
return ;
}
// A short-height bar dial for reasoning effort, drawn as SVG rects that ascend
// left→right; filled up to the current level. Rendered inside the node card.
function ThinkDial({ x, y, levels, level, tier }: { x: number; y: number; levels?: string[]; level: number; tier: "lead" | "worker" }) {
const bars = thinkBars(levels, level, tier);
const gap = tier === "lead" ? 2 : 1.2;
const maxH = tier === "lead" ? 9 : 7;
let cx = x;
return (
{bars.map((b, i) => {
const bx = cx; cx += b.w + gap;
return ;
})}
);
}
function Node(props: {
node: HierarchyPointNode; ox: number; nodeW: number; sessionId: string; inactiveTeam: boolean;
rt?: AgentRuntime; collapsed: boolean; onToggle: (name: string, hasKids: boolean) => void; statusMode?: TopoStatusMode;
}) {
const { node, ox, rt } = props;
const data = node.data;
// Hooks run unconditionally (rules-of-hooks); the synthetic branch below reads
// status but ignores it.
const status = useStatus(props.sessionId, data.name, props.inactiveTeam, rt?.status, props.statusMode);
// Model-capability lookup for the dial's fallback (K3). Read here so hook order
// stays stable across the synthetic early-return below.
const modelLevels = useHive((s) => s.modelLevels);
// A synthetic grouping root (no real orchestrator) renders as a plain,
// non-interactive group header — no CTX/TOK-S stats, not clickable (E4).
if ((data as any).synthetic) {
const W = props.nodeW;
return (
{data.name}
);
}
const sk = statusKey(status);
const tokens = (rt?.inputTokens || 0) + (rt?.outputTokens || 0);
const hasKids = (data.children?.length || 0) > 0;
const role = data.agentType || data.role || "member";
const isOrchestrator = role === "orchestrator";
const isLead = isOrchestrator || role === "lead" || hasKids;
const tier: "lead" | "worker" = isLead ? "lead" : "worker";
const model = data.model || rt?.model;
// contextPct is unknown (no runtime / not yet reported) vs genuinely 0. Track
// the distinction so the CTX bar shows "—" for unknown instead of a misleading
// "0%" that reads as "empty context" (Phase 3.4).
const ctxKnown = rt?.contextPct != null && Number.isFinite(rt.contextPct);
const ctxPct = ctxKnown ? Math.max(0, Math.min(100, Math.round(rt!.contextPct as number))) : 0;
const level = thinkLevel(model, rt?.thinking);
// Dial level resolution (K3/Decision 6): node sidecar → rt sidecar → /models
// lookup by effective model → undefined (plain text, no invented ladder).
const resolvedLevels = resolveDialLevels((data as any).thinkingLevels, rt?.thinkingLevels, model, modelLevels);
const tps = tokPerSec(rt?.inputTokens, rt?.outputTokens, rt?.elapsedMs, rt?.runStartInputTokens, rt?.runStartOutputTokens);
const openLog = () => {
if (props.sessionId) viewAgent({ sessionId: props.sessionId, name: data.name, color: data.color, status, model });
};
const onClick = (ev: React.MouseEvent) => { ev.stopPropagation(); openLog(); };
const onKeyDown = (ev: React.KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); ev.stopPropagation(); openLog(); }
};
const W = props.nodeW, H = NODE_H;
const ctxCol = ctxColor(ctxPct);
const tag = modelTag(model);
const tagW = tagWidth(tag);
// Agent identity color (every agent carries one). Used as a left accent so the
// topology speaks the same per-agent color language as the activity feed and
// leaderboard — distinct from the status dot, which stays the status signal.
const agentColor = data.color || "var(--brand)";
const safeName = data.name.replace(/[^a-z0-9]/gi, "");
const clipId = `nclip-${safeName}`;
const gradId = `nwash-${safeName}`;
// Enforcement contract shown as a hover tooltip (Phase 6.1).
const enforcement = enforcementSummary(data);
return (
{/* Enforcement contract on hover (Phase 6.1): domains, commit, gates, etc. */}
{enforcement ? `${data.name}\n${enforcement}` : data.name}
{/* wash carries the full card width, fading left→right into transparent */}
{/* identity accent: colored left edge + full-width wash, clipped to the card */}
{/* Row 1: status dot + halo, id, model badge */}
{sk === "running" && }
{/* id gets the width left of the model pill; ~6.6px per char at 12px */}
{clip(data.name, Math.max(6, Math.floor((W - 12 - tagW - 27 - 6) / 6.6)))}{tag}
{/* Row 2: CTX bar */}
CTX{ctxKnown ? `${ctxPct}%` : "—"}
{/* Row 3: THINK dial · TOK/S · TOTAL */}
THINK{thinkName(model, level).toUpperCase()}TOK/S{tps ? Math.round(tps) : "—"}TOTAL{fmtNum(tokens)}
{/* collapse toggle rides the bottom-center edge, between the card and its
children — clear of the TOTAL/TOK-S/THINK stats. */}
{hasKids && (
{ ev.stopPropagation(); props.onToggle(data.name, hasKids); }}
onPointerDown={(ev) => ev.stopPropagation()}>
{props.collapsed ? "+" : "−"}
)}
);
}