Files
Claude-Code-Monitor/client/src/components/workflows/AgentCollaborationNetwork.tsx
T
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00

644 lines
23 KiB
TypeScript

/**
* @file AgentCollaborationNetwork.tsx
* @description Defines the AgentCollaborationNetwork React component that visualizes the collaboration between different agent types in a directed graph format using D3.js. The component takes in effectiveness data for each agent type and the edges representing their interactions, and renders an interactive force-directed graph where nodes represent agent types and edges represent the frequency of sequential runs. The graph includes tooltips for detailed information on hover and a legend for clarity.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `AgentCollaborationNetworkProps` — exported API; see TSDoc on the symbol for behavior.
* - `AgentCollaborationNetwork` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **AgentCollaborationNetworkProps**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **AgentCollaborationNetwork**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useRef, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import * as d3 from "d3";
// ── Types ──────────────────────────────────────────────────────────────────────
export interface AgentCollaborationNetworkProps {
effectiveness: Array<{
subagent_type: string;
total: number;
completed: number;
errors: number;
sessions: number;
successRate: number;
}>;
edges: Array<{ source: string; target: string; weight: number }>;
}
interface PipelineNode extends d3.SimulationNodeDatum {
id: string;
total: number;
sessions: number;
successRate: number;
colorIndex: number;
}
interface PipelineLink extends d3.SimulationLinkDatum<PipelineNode> {
weight: number;
label: string;
}
// ── Constants ──────────────────────────────────────────────────────────────────
const PALETTE = [
"#6366f1",
"#3b82f6",
"#22c55e",
"#a855f7",
"#f59e0b",
"#ec4899",
"#06b6d4",
"#f97316",
"#ef4444",
"#14b8a6",
];
const STROKE_PALETTE = [
"#818cf8",
"#60a5fa",
"#4ade80",
"#c084fc",
"#fbbf24",
"#f472b6",
"#22d3ee",
"#fb923c",
"#f87171",
"#2dd4bf",
];
const MIN_R = 20;
const MAX_R = 44;
// ── Safe tooltip DOM builder ──
function appendTooltipRow(parent: HTMLElement, label: string, value: string) {
const row = document.createElement("div");
row.style.cssText = "display:flex;justify-content:space-between;gap:16px;font-size:11px";
const lbl = document.createElement("span");
lbl.style.color = "#64748b";
lbl.textContent = label;
const val = document.createElement("span");
val.style.cssText = "color:#cbd5e1;font-weight:500";
val.textContent = value;
row.appendChild(lbl);
row.appendChild(val);
parent.appendChild(row);
}
function appendTooltipDescription(parent: HTMLElement, text: string) {
const p = document.createElement("p");
p.style.cssText =
"font-size:11px;color:#94a3b8;line-height:1.45;margin:8px 0 0;padding-top:8px;border-top:1px solid #2a2a4a";
p.textContent = text;
parent.appendChild(p);
}
type TFn = (key: string, options?: Record<string, unknown>) => string;
function describeNodeRole(d: PipelineNode, t: TFn): string {
const sr = Math.round(d.successRate);
let healthKey: string;
if (sr >= 95) healthKey = "pipeline.tooltip.health.perfect";
else if (sr >= 80) healthKey = "pipeline.tooltip.health.healthy";
else if (sr >= 50) healthKey = "pipeline.tooltip.health.shaky";
else healthKey = "pipeline.tooltip.health.failing";
return t("pipeline.tooltip.nodeDescFmt", {
id: d.id,
total: d.total,
sessions: d.sessions,
rate: sr,
health: t(healthKey),
});
}
function showTooltip(
el: HTMLDivElement,
d: PipelineNode,
x: number,
y: number,
t: TFn,
totalSpawns: number
) {
el.textContent = "";
const title = document.createElement("p");
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0 0 2px";
title.textContent = d.id;
el.appendChild(title);
const subtitle = document.createElement("p");
subtitle.style.cssText =
"font-size:10px;color:#64748b;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em";
subtitle.textContent = t("pipeline.tooltip.agentType");
el.appendChild(subtitle);
const sharePct = totalSpawns > 0 ? `${((d.total / totalSpawns) * 100).toFixed(1)}%` : "-";
const rows: [string, string][] = [
[t("pipeline.spawned"), String(d.total) + t("pipeline.spawns")],
[t("pipeline.tooltip.shareOfAllSpawns"), sharePct],
[t("pipeline.inSessions"), String(d.sessions)],
[t("effectiveness.success"), Math.round(d.successRate) + "%"],
];
for (const [label, value] of rows) {
appendTooltipRow(el, label, value);
}
appendTooltipDescription(el, describeNodeRole(d, t));
el.style.maxWidth = "320px";
positionTooltipAt(el, x, y);
}
/**
* Position a tooltip so its top-right corner sits near (x, y), but clamped to
* the viewport so it never disappears behind the sidebar or the right edge.
* Sets opacity to 1 to fade the tooltip in via its CSS transition.
*/
function positionTooltipAt(el: HTMLDivElement, x: number, y: number) {
el.style.opacity = "0";
const w = el.offsetWidth || 280;
const h = el.offsetHeight || 160;
const margin = 8;
// Default: place just below-right of the cursor
let left = x + 14;
let top = y + 14;
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
if (left < margin) left = margin;
if (top + h > window.innerHeight - margin) top = y - h - 14;
if (top < margin) top = margin;
el.style.left = `${left}px`;
el.style.top = `${top}px`;
el.style.transform = "";
// Trigger fade-in on the next frame for a smooth transition.
requestAnimationFrame(() => {
el.style.opacity = "1";
});
}
// ── Component ──────────────────────────────────────────────────────────────────
export function AgentCollaborationNetwork({
effectiveness,
edges,
}: AgentCollaborationNetworkProps) {
const { t } = useTranslation("workflows");
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const simulationRef = useRef<d3.Simulation<PipelineNode, PipelineLink> | null>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
// Memoize so D3 effect only reruns when props change, not on every render
const { nodes, links, isEmpty } = useMemo(() => {
const nodeMap = new Map<string, PipelineNode>();
effectiveness.forEach((item, i) => {
nodeMap.set(item.subagent_type, {
id: item.subagent_type,
total: item.total,
sessions: item.sessions,
successRate: item.successRate,
colorIndex: i % PALETTE.length,
});
});
const seen = new Set<string>();
const links: PipelineLink[] = [];
for (const e of edges) {
if (e.source === e.target) continue;
if (!nodeMap.has(e.source) || !nodeMap.has(e.target)) continue;
const key = `${e.source}${e.target}`;
if (seen.has(key)) continue;
seen.add(key);
links.push({
source: e.source,
target: e.target,
weight: e.weight,
label: `${e.weight}x`,
});
}
const nodes = [...nodeMap.values()];
return { nodes, links, isEmpty: nodes.length === 0 || links.length === 0 };
}, [effectiveness, edges]);
// D3 simulation - only depends on memoized data
useEffect(() => {
const svg = svgRef.current;
const container = containerRef.current;
if (!svg || !container || isEmpty) return;
simulationRef.current?.stop();
const width = container.clientWidth;
const height = Math.max(450, Math.min(650, width * 0.6));
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.style.width = `${width}px`;
svg.style.height = `${height}px`;
const root = d3.select(svg);
root.selectAll("*").remove();
// Arrow marker
const defs = root.append("defs");
defs
.append("marker")
.attr("id", "arrowhead")
.attr("viewBox", "0 0 10 6")
.attr("refX", 10)
.attr("refY", 3)
.attr("markerWidth", 8)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,0 L10,3 L0,6 Z")
.attr("fill", "#64748b");
// Clone data
const simNodes = nodes.map((n) => ({ ...n }));
const nodeById = new Map(simNodes.map((n) => [n.id, n]));
const simLinks: PipelineLink[] = links.map((l) => ({
...l,
source: nodeById.get(l.source as string) ?? (l.source as string),
target: nodeById.get(l.target as string) ?? (l.target as string),
}));
// Scales
const ext = d3.extent(simNodes, (n) => n.total) as [number, number];
const rScale = d3
.scaleSqrt()
.domain([Math.max(1, ext[0] ?? 1), Math.max(2, ext[1] ?? 2)])
.range([MIN_R, MAX_R])
.clamp(true);
const wExt = d3.extent(simLinks, (l) => l.weight) as [number, number];
const strokeScale = d3
.scaleLinear()
.domain([Math.max(1, wExt[0] ?? 1), Math.max(2, wExt[1] ?? 2)])
.range([1.5, 5])
.clamp(true);
// ── Links (paths for curves) ──
const linkGroup = root.append("g");
const linkEls = linkGroup
.selectAll<SVGPathElement, PipelineLink>("path")
.data(simLinks)
.join("path")
.attr("fill", "none")
.attr("stroke", "#64748b")
.attr("stroke-opacity", 0.55)
.attr("stroke-width", (d) => Math.max(1.5, strokeScale(d.weight)))
.attr("marker-end", "url(#arrowhead)");
// ── Edge labels ──
const labelGroup = root.append("g");
const edgeLabels = labelGroup
.selectAll<SVGTextElement, PipelineLink>("text")
.data(simLinks)
.join("text")
.attr("text-anchor", "middle")
.attr("fill", "#94a3b8")
.attr("font-size", "9px")
.attr("font-weight", "600")
.attr("font-family", "Inter, sans-serif")
.attr("pointer-events", "none")
.text((d) => d.label);
// ── Nodes ──
const nodeGroup = root.append("g");
const nodeEls = nodeGroup
.selectAll<SVGGElement, PipelineNode>("g")
.data(simNodes, (d) => d.id)
.join("g")
.attr("cursor", "grab");
nodeEls
.append("circle")
.attr("r", (d) => rScale(d.total))
.attr("fill", (d) => PALETTE[d.colorIndex] ?? "#6366f1")
.attr("fill-opacity", 0.8)
.attr("stroke", (d) => STROKE_PALETTE[d.colorIndex] ?? "#818cf8")
.attr("stroke-width", 2);
nodeEls
.append("text")
.attr("text-anchor", "middle")
.attr("dy", (d) => rScale(d.total) + 14)
.attr("fill", "#cbd5e1")
.attr("font-size", "10px")
.attr("font-weight", "500")
.attr("font-family", "Inter, sans-serif")
.attr("pointer-events", "none")
.text((d) => (d.id.length > 16 ? d.id.slice(0, 14) + "\u2026" : d.id));
// ── Invisible wider hit areas for edge hover ──
const hitGroup = root.append("g");
const linkHits = hitGroup
.selectAll<SVGPathElement, PipelineLink>("path")
.data(simLinks)
.join("path")
.attr("fill", "none")
.attr("stroke", "transparent")
.attr("stroke-width", 16)
.attr("cursor", "pointer");
// ── Hover - pure DOM, zero React re-renders ──
const tipEl = tooltipRef.current;
// Edge hover
const totalSpawns = simNodes.reduce((s, n) => s + n.total, 0);
linkHits
.on("mouseenter", (event: MouseEvent, d: PipelineLink) => {
const src = d.source as PipelineNode;
const tgt = d.target as PipelineNode;
// Highlight this edge
linkEls
.attr("stroke-opacity", (l) => (l === d ? 1 : 0.1))
.attr("stroke-width", (l) =>
l === d ? Math.max(3, strokeScale(l.weight) + 1) : Math.max(1.5, strokeScale(l.weight))
);
edgeLabels.attr("fill-opacity", (l) => (l === d ? 1 : 0.15));
if (tipEl) {
tipEl.textContent = "";
const title = document.createElement("p");
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0 0 2px";
title.textContent = `${src.id} \u2192 ${tgt.id}`;
tipEl.appendChild(title);
const subtitle = document.createElement("p");
subtitle.style.cssText =
"font-size:10px;color:#64748b;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em";
subtitle.textContent = t("pipeline.tooltip.edge");
tipEl.appendChild(subtitle);
const shareOfSrc = src.total > 0 ? `${((d.weight / src.total) * 100).toFixed(1)}%` : "-";
const shareOfTgt = tgt.total > 0 ? `${((d.weight / tgt.total) * 100).toFixed(1)}%` : "-";
const rows: [string, string][] = [
[t("pipeline.tooltip.sequentialPairs"), `${d.weight}\u00d7`],
[t("pipeline.tooltip.shareOfSrcFmt", { source: src.id }), shareOfSrc],
[t("pipeline.tooltip.shareOfTgtFmt", { target: tgt.id }), shareOfTgt],
[t("pipeline.tooltip.totalSpawnsFmt", { id: src.id }), String(src.total)],
[t("pipeline.tooltip.totalSpawnsFmt", { id: tgt.id }), String(tgt.total)],
];
for (const [label, value] of rows) {
appendTooltipRow(tipEl, label, value);
}
appendTooltipDescription(
tipEl,
t("pipeline.tooltip.edgeDescFmt", {
source: src.id,
target: tgt.id,
count: d.weight,
})
);
tipEl.style.maxWidth = "320px";
positionTooltipAt(tipEl, event.clientX, event.clientY);
}
})
.on("mouseleave", () => {
linkEls
.attr("stroke-opacity", 0.55)
.attr("stroke-width", (d) => Math.max(1.5, strokeScale(d.weight)));
edgeLabels.attr("fill-opacity", 1);
if (tipEl) tipEl.style.opacity = "0";
});
// Node hover
nodeEls
.on("mouseenter", (event: MouseEvent, d: PipelineNode) => {
linkEls.attr("stroke-opacity", (l) => {
const s = (l.source as PipelineNode).id;
const t = (l.target as PipelineNode).id;
return s === d.id || t === d.id ? 0.9 : 0.08;
});
edgeLabels.attr("fill-opacity", (l) => {
const s = (l.source as PipelineNode).id;
const t = (l.target as PipelineNode).id;
return s === d.id || t === d.id ? 1 : 0.15;
});
d3.select(event.currentTarget as SVGGElement)
.select("circle")
.attr("stroke-width", 4);
if (tipEl) showTooltip(tipEl, d, event.clientX, event.clientY, t, totalSpawns);
})
.on("mouseleave", () => {
linkEls.attr("stroke-opacity", 0.55);
edgeLabels.attr("fill-opacity", 1);
nodeEls.selectAll("circle").attr("stroke-width", 2);
if (tipEl) tipEl.style.opacity = "0";
});
// ── Drag ──
const drag = d3
.drag<SVGGElement, PipelineNode>()
.on("start", (event, d) => {
if (!event.active) simulation.alphaTarget(0.12).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
});
nodeEls.call(drag);
// ── Simulation ──
const simulation = d3
.forceSimulation<PipelineNode>(simNodes)
.force(
"link",
d3
.forceLink<PipelineNode, PipelineLink>(simLinks)
.id((d) => d.id)
.distance(250)
)
.force("charge", d3.forceManyBody<PipelineNode>().strength(-800))
.force("center", d3.forceCenter(width / 2, height / 2))
.force(
"collision",
d3.forceCollide<PipelineNode>().radius((d) => rScale(d.total) + 30)
)
.force("x", d3.forceX(width / 2).strength(0.03))
.force("y", d3.forceY(height / 2).strength(0.03))
.alpha(0.5)
.on("tick", () => {
// Clamp nodes inside viewBox
for (const n of simNodes) {
const r = rScale(n.total) + 16;
n.x = Math.max(r, Math.min(width - r, n.x ?? width / 2));
n.y = Math.max(r, Math.min(height - r, n.y ?? height / 2));
}
// Build path for each edge (shared by visible + hit area)
const pathFor = (d: PipelineLink) => {
const s = d.source as PipelineNode;
const t = d.target as PipelineNode;
const sx = s.x ?? 0,
sy = s.y ?? 0;
const tx = t.x ?? 0,
ty = t.y ?? 0;
const dx = tx - sx,
dy = ty - sy;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const sr = rScale(s.total);
const tr = rScale(t.total) + 8;
const x1 = sx + (dx / dist) * sr;
const y1 = sy + (dy / dist) * sr;
const x2 = tx - (dx / dist) * tr;
const y2 = ty - (dy / dist) * tr;
const mx = (x1 + x2) / 2 - dy * 0.1;
const my = (y1 + y2) / 2 + dx * 0.1;
return `M${x1},${y1} Q${mx},${my} ${x2},${y2}`;
};
linkEls.attr("d", pathFor);
linkHits.attr("d", pathFor);
edgeLabels.each(function (d) {
const s = d.source as PipelineNode;
const t = d.target as PipelineNode;
const sx = s.x ?? 0,
sy = s.y ?? 0,
tx = t.x ?? 0,
ty = t.y ?? 0;
const ddx = tx - sx,
ddy = ty - sy;
d3.select(this)
.attr("x", (sx + tx) / 2 - ddy * 0.1)
.attr("y", (sy + ty) / 2 + ddx * 0.1 - 4);
});
nodeEls.attr("transform", (d) => `translate(${d.x ?? 0},${d.y ?? 0})`);
});
simulationRef.current = simulation as d3.Simulation<PipelineNode, PipelineLink>;
return () => {
simulation.stop();
};
}, [nodes, links, isEmpty, t]);
if (isEmpty) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-sm font-medium text-gray-400">{t("pipeline.noData")}</p>
<p className="text-xs text-gray-600 mt-1">{t("pipeline.noDataDesc")}</p>
</div>
);
}
const handleContainerLeave = () => {
const tip = tooltipRef.current;
if (tip) tip.style.opacity = "0";
};
return (
<div ref={containerRef} className="w-full relative" onMouseLeave={handleContainerLeave}>
<svg
ref={svgRef}
style={{ display: "block", width: "100%", background: "transparent" }}
aria-label={t("pipeline.ariaLabel")}
role="img"
onMouseLeave={handleContainerLeave}
/>
<div
ref={tooltipRef}
role="tooltip"
aria-hidden="true"
className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none"
style={{
opacity: 0,
left: 0,
top: 0,
minWidth: 172,
transition: "opacity 120ms ease-out",
}}
/>
<div className="flex flex-wrap items-center gap-3 mt-3 px-1">
<span className="text-[10px] text-gray-600 uppercase tracking-widest font-medium">
{t("pipeline.legend")}
</span>
{nodes.map((n) => (
<div key={n.id} className="flex items-center gap-1.5">
<span
className="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{
backgroundColor: PALETTE[n.colorIndex] ?? PALETTE[0],
border: `1.5px solid ${STROKE_PALETTE[n.colorIndex] ?? STROKE_PALETTE[0]}`,
}}
/>
<span className="text-[11px] text-gray-500">{n.id}</span>
</div>
))}
<div className="flex items-center gap-1.5 ml-2">
<svg width="20" height="8" className="flex-shrink-0">
<line x1="0" y1="4" x2="14" y2="4" stroke="#64748b" strokeWidth="1.5" />
<polygon points="14,1 20,4 14,7" fill="#64748b" />
</svg>
<span className="text-[11px] text-gray-500">{t("pipeline.legendDesc")}</span>
</div>
</div>
</div>
);
}