Files
Claude-Code-Monitor/client/src/components/workflows/OrchestrationDAG.tsx
T
nntrivi2001 b673363351 feat(theme): dark/light mode with a Radix Colors-based palette
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.

- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
  tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
  fg.primary/secondary/muted, status.success/danger/warning. One class flip
  on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
  prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
  scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
  every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
  client/src onto the new tokens, so every badge/button/component pulls the
  same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
  constants — slate/blue/green/red/amber steps 1-12 — adopted after three
  rounds of hand-picked values that kept overshooting (flat, then too dark,
  then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
  dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
  visual language (border + text + translucent wash of the same status
  color); `current` alone stays a solid accent fill, the one state that
  gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
  Workspace lane-detail header already showing them.

Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
2026-07-31 10:54:31 +07:00

1086 lines
37 KiB
TypeScript

/**
* @file OrchestrationDAG.tsx
* @description Defines the OrchestrationDAG React component that visualizes orchestration data as a directed acyclic graph (DAG) using D3.js. The component takes in orchestration data, processes it to build a graph structure with nodes and edges, and renders it as an SVG. It includes interactive features such as tooltips on hover and click handlers for nodes. The graph is styled with gradients and colors to differentiate between different types of nodes and outcomes, providing a clear visual representation of the orchestration process.
* @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`.
*
* ## Internal dependencies
* - `../../lib/types`
*
* ## Public surface
* - `OrchestrationDAG` — 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).
* -----------------------------------------------------------------------------
* **OrchestrationDAG**
* 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, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import * as d3 from "d3";
import type { OrchestrationData } from "../../lib/types";
// ── Types ─────────────────────────────────────────────────────────────────────
interface OrchestrationDAGProps {
data: OrchestrationData;
onNodeClick?: (nodeType: string) => void;
selectedNode?: string | null;
}
interface DAGNode {
id: string;
label: string;
count: number;
layer: number;
kind: "session" | "main" | "subagent" | "nested" | "outcome";
meta?: {
completed?: number;
errors?: number;
subagent_type?: string;
status?: string;
};
x: number;
y: number;
width: number;
height: number;
}
interface DAGEdge {
source: string;
target: string;
weight: number;
sourceNode?: DAGNode;
targetNode?: DAGNode;
}
type TFn = (key: string, options?: Record<string, unknown>) => string;
// ── Constants ─────────────────────────────────────────────────────────────────
const NODE_W = 136;
const NODE_H = 44;
const NODE_RX = 8;
const LAYER_GAP = 80;
const NODE_V_GAP = 8;
const PADDING_X = 8;
const PADDING_TOP = 44;
const PADDING_BOTTOM = 40;
const MAX_SUBAGENT_NODES = 7;
const MAX_EDGE_STROKE = 10;
const MIN_EDGE_STROKE = 1.5;
const BADGE_W = 28;
const BADGE_H = 14;
// Layer labels are now computed via i18n inside the component
const OUTCOME_COLORS: Record<string, { fill: string; stroke: string; text: string }> = {
completed: { fill: "#052e16", stroke: "#16a34a", text: "#4ade80" },
error: { fill: "#1f0808", stroke: "#dc2626", text: "#f87171" },
abandoned: { fill: "#1c1a04", stroke: "#ca8a04", text: "#facc15" },
};
const KIND_GRADIENTS: Record<
DAGNode["kind"],
{ id: string; stops: Array<{ offset: string; color: string }> }
> = {
session: {
id: "grad-session",
stops: [
{ offset: "0%", color: "#312e81" },
{ offset: "100%", color: "#4338ca" },
],
},
main: {
id: "grad-main",
stops: [
{ offset: "0%", color: "#1e3a5f" },
{ offset: "100%", color: "#1d4ed8" },
],
},
subagent: {
id: "grad-subagent",
stops: [
{ offset: "0%", color: "#052e16" },
{ offset: "100%", color: "#166534" },
],
},
nested: {
id: "grad-nested",
stops: [
{ offset: "0%", color: "#134e4a" },
{ offset: "100%", color: "#0f766e" },
],
},
outcome: {
id: "grad-outcome",
stops: [
{ offset: "0%", color: "#1e1b4b" },
{ offset: "100%", color: "#4338ca" },
],
},
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function isEmpty(data: OrchestrationData): boolean {
return (
data.sessionCount === 0 &&
data.mainCount === 0 &&
data.subagentTypes.length === 0 &&
data.outcomes.length === 0
);
}
function successRate(completed: number, total: number): string {
if (total === 0) return "-";
return `${Math.round((completed / total) * 100)}%`;
}
function outcomeColorSet(status: string) {
return OUTCOME_COLORS[status] ?? { fill: "#1a1a28", stroke: "#363650", text: "#9ca3af" };
}
// ── Layout builder ────────────────────────────────────────────────────────────
function buildGraph(
data: OrchestrationData,
t: (key: string, options?: Record<string, unknown>) => string
): {
nodes: DAGNode[];
edges: DAGEdge[];
svgWidth: number;
svgHeight: number;
} {
const rawNodes: Omit<DAGNode, "x" | "y">[] = [];
// Layer 0 - sessions
rawNodes.push({
id: "sessions",
label: t("orchestration.sessions"),
count: data.sessionCount,
layer: 0,
kind: "session",
width: NODE_W,
height: NODE_H,
});
// Layer 1 - main agent
rawNodes.push({
id: "main",
label: t("orchestration.mainAgent"),
count: data.mainCount,
layer: 1,
kind: "main",
width: NODE_W,
height: NODE_H,
});
// Layer 2 - subagent types (deduplicated, capped at MAX_SUBAGENT_NODES)
const subagentMap = new Map<string, { count: number; completed: number; errors: number }>();
for (const s of data.subagentTypes) {
const key = s.subagent_type || "unknown";
const existing = subagentMap.get(key);
if (existing) {
existing.count += s.count;
existing.completed += s.completed;
existing.errors += s.errors;
} else {
subagentMap.set(key, { count: s.count, completed: s.completed, errors: s.errors });
}
}
// Sort by count desc, take top N
const sortedSubagents = [...subagentMap.entries()].sort((a, b) => b[1].count - a[1].count);
const visible = sortedSubagents.slice(0, MAX_SUBAGENT_NODES);
const overflow = sortedSubagents.slice(MAX_SUBAGENT_NODES);
for (const [type, stats] of visible) {
rawNodes.push({
id: `subagent:${type}`,
label: type.length > 14 ? type.slice(0, 12) + "..." : type,
count: stats.count,
layer: 2,
kind: "subagent",
width: NODE_W,
height: NODE_H,
meta: { completed: stats.completed, errors: stats.errors, subagent_type: type },
});
}
if (overflow.length > 0) {
const overflowTotal = overflow.reduce((s, [, v]) => s + v.count, 0);
rawNodes.push({
id: "subagent:__overflow",
label: t("common:plusMore", { count: overflow.length }),
count: overflowTotal,
layer: 2,
kind: "subagent",
width: NODE_W,
height: NODE_H,
meta: {
completed: 0,
errors: 0,
subagent_type: t("orchestration.otherTypes", { count: overflow.length }),
},
});
}
// Layer 3 - compactions (context compressions)
const compactions = (data as unknown as { compactions?: { total: number; sessions: number } })
.compactions;
const compTotal = compactions?.total ?? 0;
const compSessions = compactions?.sessions ?? 0;
rawNodes.push({
id: "compaction:total",
label: t("orchestration.compactions"),
count: compTotal,
layer: 3,
kind: "nested",
width: NODE_W,
height: NODE_H,
meta: { subagent_type: "compaction" },
});
if (compSessions > 0) {
rawNodes.push({
id: "compaction:sessions",
label: `${compSessions} sessions`,
count: compSessions,
layer: 3,
kind: "nested",
width: NODE_W,
height: NODE_H,
meta: { subagent_type: "compaction" },
});
}
// Layer 4 - outcomes
const outcomeMap = new Map<string, number>();
for (const o of data.outcomes) {
outcomeMap.set(o.status, (outcomeMap.get(o.status) ?? 0) + o.count);
}
if (outcomeMap.size === 0) {
outcomeMap.set("completed", 0);
}
for (const [status, count] of outcomeMap) {
rawNodes.push({
id: `outcome:${status}`,
label: status.charAt(0).toUpperCase() + status.slice(1),
count,
layer: 4,
kind: "outcome",
width: NODE_W,
height: NODE_H,
meta: { status },
});
}
// Compute per-layer node lists
const layers: Omit<DAGNode, "x" | "y">[][] = [[], [], [], [], []];
for (const n of rawNodes) {
layers[n.layer]?.push(n);
}
// Compute SVG dimensions
const numLayers = layers.length;
const maxNodesInLayer = Math.max(...layers.map((l) => l.length));
const svgWidth = PADDING_X * 2 + numLayers * NODE_W + (numLayers - 1) * LAYER_GAP;
const naturalHeight =
PADDING_TOP +
PADDING_BOTTOM +
maxNodesInLayer * NODE_H +
Math.max(0, maxNodesInLayer - 1) * NODE_V_GAP;
const svgHeight = Math.min(naturalHeight, 520);
// Assign x/y positions
const nodes: DAGNode[] = [];
for (let li = 0; li < layers.length; li++) {
const layer = layers[li] ?? [];
const layerX = PADDING_X + li * (NODE_W + LAYER_GAP);
const layerTotalH = layer.length * NODE_H + Math.max(0, layer.length - 1) * NODE_V_GAP;
const layerStartY = PADDING_TOP + (svgHeight - PADDING_TOP - PADDING_BOTTOM - layerTotalH) / 2;
for (let ni = 0; ni < layer.length; ni++) {
const raw = layer[ni]!;
nodes.push({
...raw,
x: layerX,
y: layerStartY + ni * (NODE_H + NODE_V_GAP),
});
}
}
// Build nodeMap for edge lookup
const nodeMap = new Map<string, DAGNode>(nodes.map((n) => [n.id, n]));
// Build edges from data.edges + synthetic structural edges
const edgeSet = new Set<string>();
const rawEdges: DAGEdge[] = [];
const addEdge = (source: string, target: string, weight: number) => {
const key = `${source}${target}`;
if (edgeSet.has(key)) return;
edgeSet.add(key);
const sn = nodeMap.get(source);
const tn = nodeMap.get(target);
if (sn && tn) {
rawEdges.push({ source, target, weight, sourceNode: sn, targetNode: tn });
}
};
// Sessions → Main
addEdge("sessions", "main", data.mainCount || 1);
// Main → each subagent type (use data.edges if available, else uniform)
const subagentIds = nodes.filter((n) => n.kind === "subagent").map((n) => n.id);
const compactionIds = nodes.filter((n) => n.kind === "nested").map((n) => n.id);
const outcomeIds = nodes.filter((n) => n.kind === "outcome").map((n) => n.id);
for (const edge of data.edges) {
// Map source/target names to node IDs
const srcId =
edge.source === "main"
? "main"
: edge.source === "sessions"
? "sessions"
: (subagentIds.find((id) => id === `subagent:${edge.source}`) ??
compactionIds.find((id) => id === `nested:${edge.source}`) ??
`subagent:${edge.source}`);
const tgtId =
edge.target === "main"
? "main"
: (outcomeIds.find((id) => id === `outcome:${edge.target}`) ??
compactionIds.find((id) => id === `nested:${edge.target}`) ??
subagentIds.find((id) => id === `subagent:${edge.target}`) ??
`subagent:${edge.target}`);
addEdge(srcId, tgtId, edge.weight);
}
// Structural fallbacks: main → subagents if no data edges cover them
for (const sid of subagentIds) {
const hasEdge = rawEdges.some((e) => e.target === sid);
if (!hasEdge) {
const subNode = nodeMap.get(sid);
addEdge("main", sid, subNode?.count ?? 1);
}
}
// Subagents → compaction nodes
for (const cid of compactionIds) {
const cNode = nodeMap.get(cid);
const weight = Math.max(1, cNode?.count ?? 1);
// Connect from each subagent to compaction
for (const sid of subagentIds) {
addEdge(sid, cid, Math.max(0.5, Math.round(weight / Math.max(subagentIds.length, 1))));
}
}
// Compaction → outcomes
for (const cid of compactionIds) {
for (const oid of outcomeIds) {
const outcomeNode = nodeMap.get(oid);
const perComp = Math.max(
0.5,
Math.round((outcomeNode?.count ?? 1) / Math.max(compactionIds.length, 1))
);
addEdge(cid, oid, perComp);
}
}
// Also connect subagents directly to outcomes
for (const sid of subagentIds) {
for (const oid of outcomeIds) {
const outcomeNode = nodeMap.get(oid);
const perSub = Math.max(
0.5,
Math.round((outcomeNode?.count ?? 1) / Math.max(subagentIds.length, 1))
);
addEdge(sid, oid, perSub);
}
}
return { nodes, edges: rawEdges, svgWidth, svgHeight };
}
// ── Component ─────────────────────────────────────────────────────────────────
export function OrchestrationDAG({ data, onNodeClick, selectedNode }: OrchestrationDAGProps) {
const { t } = useTranslation("workflows");
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tipRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
// Imperative tooltip helpers - bypass React entirely so hover never re-renders the chart.
const hideTip = useCallback(() => {
const tip = tipRef.current;
if (tip) tip.style.opacity = "0";
}, []);
const showTipForNode = useCallback(
(node: DAGNode, anchorEl: SVGGElement) => {
const tip = tipRef.current;
const container = containerRef.current;
if (!tip || !container) return;
// Build content
buildDAGTooltipContent(tip, node, t);
// Anchor the tooltip above the hovered node, clamped to the viewport.
const nodeRect = anchorEl.getBoundingClientRect();
// Show first so we can measure
tip.style.opacity = "0";
tip.style.display = "block";
const tipW = tip.offsetWidth || 260;
const tipH = tip.offsetHeight || 160;
const margin = 8;
// Default: center horizontally above the node
let left = nodeRect.left + nodeRect.width / 2 - tipW / 2;
// Clamp to viewport horizontally
if (left < margin) left = margin;
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
// Vertical: prefer above the node, flip below if no space
let top = nodeRect.top - tipH - 10;
if (top < margin) top = nodeRect.bottom + 10;
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
tip.style.opacity = "1";
},
[t]
);
const layerLabels = [
t("orchestration.layers.origin"),
t("orchestration.layers.mainAgent"),
t("orchestration.layers.subagentTypes"),
t("orchestration.layers.compactions"),
t("orchestration.layers.outcomes"),
];
const graph = useMemo(() => buildGraph(data, t), [data, t]);
const handleNodeClick = useCallback(
(node: DAGNode) => {
onNodeClick?.(node.id);
},
[onNodeClick]
);
// Fade-in on mount
useEffect(() => {
const id = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(id);
}, []);
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const { nodes, edges, svgHeight } = graph;
const currentLayerLabels = layerLabels;
const root = d3.select(svg);
root.selectAll("*").remove();
// ── Defs ──────────────────────────────────────────────────────────────────
const defs = root.append("defs");
// Gradients
for (const kind of Object.keys(KIND_GRADIENTS) as DAGNode["kind"][]) {
const g = KIND_GRADIENTS[kind];
const grad = defs
.append("linearGradient")
.attr("id", g.id)
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "0%");
for (const stop of g.stops) {
grad.append("stop").attr("offset", stop.offset).attr("stop-color", stop.color);
}
}
// Outcome-specific gradients
for (const [status, colors] of Object.entries(OUTCOME_COLORS)) {
const grad = defs
.append("linearGradient")
.attr("id", `grad-outcome-${status}`)
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "0%");
grad.append("stop").attr("offset", "0%").attr("stop-color", colors.fill);
grad
.append("stop")
.attr("offset", "100%")
.attr("stop-color", colors.stroke + "55");
}
// Glow filter for selected node
const glowFilter = defs
.append("filter")
.attr("id", "glow")
.attr("x", "-30%")
.attr("y", "-30%")
.attr("width", "160%")
.attr("height", "160%");
glowFilter.append("feGaussianBlur").attr("stdDeviation", "4").attr("result", "blur");
const feMerge = glowFilter.append("feMerge");
feMerge.append("feMergeNode").attr("in", "blur");
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
// Edge shadow filter
const edgeFilter = defs
.append("filter")
.attr("id", "edge-glow")
.attr("x", "-10%")
.attr("y", "-50%")
.attr("width", "120%")
.attr("height", "200%");
edgeFilter.append("feGaussianBlur").attr("stdDeviation", "2").attr("result", "blur");
const edgeMerge = edgeFilter.append("feMerge");
edgeMerge.append("feMergeNode").attr("in", "blur");
edgeMerge.append("feMergeNode").attr("in", "SourceGraphic");
// ── Layer labels ──────────────────────────────────────────────────────────
const labelLayer = root.append("g").attr("class", "layer-labels");
const layerXPositions = [0, 1, 2, 3, 4].map(
(li) => PADDING_X + li * (NODE_W + LAYER_GAP) + NODE_W / 2
);
labelLayer
.selectAll("text")
.data(currentLayerLabels)
.join("text")
.attr("x", (_, i) => layerXPositions[i] ?? 0)
.attr("y", 22)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("font-size", "10px")
.attr("font-weight", "500")
.attr("letter-spacing", "0.08em")
.attr("text-transform", "uppercase")
.text((d) => d.toUpperCase());
// ── Layer separator lines ──────────────────────────────────────────────────
const sepLayer = root.append("g").attr("class", "layer-separators");
for (let li = 1; li < 5; li++) {
const sepX = PADDING_X + li * (NODE_W + LAYER_GAP) - LAYER_GAP / 2;
sepLayer
.append("line")
.attr("x1", sepX)
.attr("y1", 32)
.attr("x2", sepX)
.attr("y2", svgHeight - PADDING_BOTTOM + 8)
.attr("stroke", "#1f1f30")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4 4");
}
// ── Edges ─────────────────────────────────────────────────────────────────
const weightExtent = d3.extent(edges, (e) => e.weight) as [number, number];
const strokeScale = d3
.scaleLinear()
.domain([Math.max(0.1, weightExtent[0] ?? 0.1), Math.max(1, weightExtent[1] ?? 1)])
.range([MIN_EDGE_STROKE, MAX_EDGE_STROKE])
.clamp(true);
const edgeLayer = root.append("g").attr("class", "edges");
for (const edge of edges) {
const sn = edge.sourceNode;
const tn = edge.targetNode;
if (!sn || !tn) continue;
const sx = sn.x + sn.width;
const sy = sn.y + sn.height / 2;
const tx = tn.x;
const ty = tn.y + tn.height / 2;
const cx = (sx + tx) / 2;
const path = `M ${sx},${sy} C ${cx},${sy} ${cx},${ty} ${tx},${ty}`;
const stroke = strokeScale(edge.weight);
const isZero = edge.weight <= 0.5;
// Shadow pass
edgeLayer
.append("path")
.attr("d", path)
.attr("fill", "none")
.attr("stroke", "#6366f1")
.attr("stroke-width", stroke + 2)
.attr("stroke-opacity", isZero ? 0 : 0.08)
.attr("filter", "url(#edge-glow)");
// Main edge
edgeLayer
.append("path")
.attr("d", path)
.attr("fill", "none")
.attr("stroke", isZero ? "#2a2a3d" : "#4f46e5")
.attr("stroke-width", isZero ? 1 : stroke)
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
.attr("stroke-linecap", "round");
}
// ── Nodes ─────────────────────────────────────────────────────────────────
const nodeLayer = root.append("g").attr("class", "nodes");
const nodeGroups = nodeLayer
.selectAll<SVGGElement, DAGNode>("g.node")
.data(nodes, (d) => d.id)
.join("g")
.attr("class", "node")
.attr("transform", (d) => `translate(${d.x},${d.y})`)
.attr("cursor", "pointer")
.attr("role", "button")
.attr("aria-label", (d) => `${d.label}: ${d.count}`)
.on("click", (_event, d) => {
handleNodeClick(d);
})
.on("mouseenter", (event: MouseEvent, d: DAGNode) => {
showTipForNode(d, event.currentTarget as SVGGElement);
d3.select(event.currentTarget as SVGGElement)
.select("rect.node-bg")
.attr("stroke-opacity", 0.9);
})
.on("mouseleave", (event: MouseEvent, d: DAGNode) => {
hideTip();
const opacity = selectedNode === d.id ? 0.9 : 0.4;
d3.select(event.currentTarget as SVGGElement)
.select("rect.node-bg")
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.attr("stroke-opacity", opacity as any);
});
// Outer glow ring for selected node
nodeGroups
.filter((d) => d.id === selectedNode)
.append("rect")
.attr("x", -3)
.attr("y", -3)
.attr("width", (d) => d.width + 6)
.attr("height", (d) => d.height + 6)
.attr("rx", NODE_RX + 3)
.attr("fill", "none")
.attr("stroke", "#6366f1")
.attr("stroke-width", 2)
.attr("filter", "url(#glow)")
.attr("opacity", 0.8);
// Background rect - outcome nodes use per-status fill
nodeGroups
.append("rect")
.attr("class", "node-bg")
.attr("width", (d) => d.width)
.attr("height", (d) => d.height)
.attr("rx", NODE_RX)
.attr("fill", (d) => {
if (d.kind === "outcome" && d.meta?.status) {
return `url(#grad-outcome-${d.meta.status})`;
}
return `url(#${KIND_GRADIENTS[d.kind].id})`;
})
.attr("stroke", (d) => {
if (d.id === selectedNode) return "#6366f1";
if (d.kind === "outcome" && d.meta?.status) {
return outcomeColorSet(d.meta.status).stroke;
}
return borderColorForKind(d.kind);
})
.attr("stroke-width", (d) => (d.id === selectedNode ? 1.5 : 1))
.attr("stroke-opacity", (d) => (d.id === selectedNode ? 0.9 : 0.4));
// Label text
nodeGroups
.append("text")
.attr("x", 10)
.attr("y", NODE_H / 2 - 3)
.attr("dominant-baseline", "middle")
.attr("fill", (d) => {
if (d.kind === "outcome" && d.meta?.status) return outcomeColorSet(d.meta.status).text;
return textColorForKind(d.kind);
})
.attr("font-size", "11px")
.attr("font-weight", "500")
.attr("font-family", "Inter, sans-serif")
.text((d) => d.label);
// Count badge background
nodeGroups
.append("rect")
.attr("x", (d) => d.width - (BADGE_W + 8))
.attr("y", NODE_H / 2 - BADGE_H / 2)
.attr("width", BADGE_W)
.attr("height", BADGE_H)
.attr("rx", 7)
.attr("fill", (d) => badgeBgForKind(d.kind, d.meta?.status))
.attr("opacity", 0.9);
// Count badge text
nodeGroups
.append("text")
.attr("x", (d) => d.width - (BADGE_W / 2 + 8))
.attr("y", NODE_H / 2 + 1)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", (d) => {
if (d.kind === "outcome" && d.meta?.status) return outcomeColorSet(d.meta.status).text;
return textColorForKind(d.kind);
})
.attr("font-size", "9px")
.attr("font-weight", "600")
.attr("font-family", "Inter, sans-serif")
.text((d) => fmtCount(d.count));
// Hide any stale tooltip when the chart re-renders so it cannot get stuck.
hideTip();
}, [graph, selectedNode, handleNodeClick, layerLabels, showTipForNode, hideTip]);
if (isEmpty(data)) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
className="w-6 h-6 text-fg-muted"
>
<circle cx="6" cy="12" r="2" />
<circle cx="18" cy="6" r="2" />
<circle cx="18" cy="18" r="2" />
<line x1="8" y1="11" x2="16" y2="7" />
<line x1="8" y1="13" x2="16" y2="17" />
</svg>
</div>
<h3 className="text-base font-medium text-fg-secondary mb-2">
{t("orchestration.noData")}
</h3>
<p className="text-sm text-fg-muted max-w-sm">{t("orchestration.noDataDesc")}</p>
</div>
);
}
return (
<div
ref={containerRef}
className="relative w-full"
style={{
opacity: mounted ? 1 : 0,
transition: "opacity 0.4s ease-out",
}}
onMouseLeave={hideTip}
>
{/* SVG DAG */}
<div className="w-full overflow-x-auto" onMouseLeave={hideTip}>
<svg
ref={svgRef}
viewBox={`0 0 ${graph.svgWidth} ${graph.svgHeight}`}
preserveAspectRatio="xMidYMid meet"
style={{
width: "100%",
minWidth: graph.svgWidth,
height: graph.svgHeight,
display: "block",
}}
aria-label={t("orchestration.ariaLabel")}
role="img"
/>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 px-1 mt-4">
<span className="text-[10px] text-fg-muted uppercase tracking-widest font-medium mr-1">
{t("orchestration.legend")}
</span>
{LEGEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center gap-1.5">
<span
className="inline-block w-3 h-3 rounded-sm flex-shrink-0"
style={{ background: item.color, border: `1px solid ${item.border}` }}
/>
<span className="text-[11px] text-fg-muted">{item.label}</span>
</div>
))}
<div className="flex items-center gap-1.5 ml-2">
<span
className="inline-block h-[2px] w-8 rounded flex-shrink-0"
style={{ background: "linear-gradient(to right, #312e81, #4f46e5)" }}
/>
<span className="text-[11px] text-fg-muted">{t("orchestration.edgeWeight")}</span>
</div>
</div>
{/* Tooltip - fixed-positioned, DOM-mutated to avoid React re-renders on hover */}
<div
ref={tipRef}
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={{
display: "none",
opacity: 0,
left: 0,
top: 0,
minWidth: 220,
maxWidth: 300,
transition: "opacity 120ms ease-out",
}}
/>
</div>
);
}
// ── Tooltip DOM builder ───────────────────────────────────────────────────────
// Builds the tooltip's content imperatively to avoid React state on hover.
function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
// Clear existing children
while (el.firstChild) el.removeChild(el.firstChild);
const meta = describeNode(node, t);
const title = document.createElement("p");
title.className = "text-xs font-semibold text-fg-secondary";
title.textContent = node.label;
el.appendChild(title);
const subtitle = document.createElement("p");
subtitle.className = "text-[10px] uppercase tracking-wider text-fg-muted mt-0.5 mb-2";
subtitle.textContent = meta.layer;
el.appendChild(subtitle);
// Stat rows
const rows: Array<[string, string]> = [[t("orchestration.count"), String(node.count)]];
if (node.kind === "subagent" && node.meta) {
const { completed = 0, errors = 0 } = node.meta;
const abandoned = Math.max(0, node.count - completed - errors);
rows.push([t("common:status.completed", { defaultValue: "Completed" }), String(completed)]);
rows.push([t("common:status.error", { defaultValue: "Errors" }), String(errors)]);
if (abandoned > 0) {
rows.push([t("common:status.abandoned", { defaultValue: "Abandoned" }), String(abandoned)]);
}
rows.push([
t("effectiveness.success", { defaultValue: "Success rate" }),
successRate(completed, node.count),
]);
}
if (node.kind === "nested" && node.meta?.subagent_type) {
rows.push([t("common:type"), node.meta.subagent_type]);
}
if (node.kind === "outcome" && node.meta?.status) {
rows.push([
t("common:statusLabel"),
t(`common:status.${node.meta.status}`, { defaultValue: node.meta.status }),
]);
}
const rowList = document.createElement("div");
rowList.style.cssText = "margin-bottom:8px";
for (const [label, value] of rows) {
const row = document.createElement("div");
row.style.cssText =
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
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;font-variant-numeric:tabular-nums";
val.textContent = value;
row.appendChild(lbl);
row.appendChild(val);
rowList.appendChild(row);
}
el.appendChild(rowList);
const desc = document.createElement("p");
desc.style.cssText =
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:0";
desc.textContent = meta.description;
el.appendChild(desc);
const hint = document.createElement("p");
hint.style.cssText = "font-size:10px;color:#64748b;line-height:1.45;margin:6px 0 0";
hint.textContent = t("orchestration.tooltip.tipClickToFilter");
el.appendChild(hint);
}
/**
* Layer-aware description for a DAG node. Pure function of the node - no I/O,
* no randomness, deterministic across renders. Returns translated strings via
* the supplied i18n function so all locales render correctly.
*/
function describeNode(node: DAGNode, t: TFn): { layer: string; description: string } {
switch (node.kind) {
case "session":
return {
layer: t("orchestration.tooltip.layer.session"),
description: t("orchestration.tooltip.desc.session"),
};
case "main":
return {
layer: t("orchestration.tooltip.layer.main"),
description: t("orchestration.tooltip.desc.main"),
};
case "subagent": {
if (node.id === "subagent:__overflow") {
return {
layer: t("orchestration.tooltip.layer.subagentOverflow"),
description: t("orchestration.tooltip.desc.subagentOverflow"),
};
}
const errors = node.meta?.errors ?? 0;
const completed = node.meta?.completed ?? 0;
const total = node.count;
let trailer = "";
if (total > 0) {
if (errors === 0 && completed === total) {
trailer = t("orchestration.tooltip.desc.subagentClean");
} else if (errors > 0 && completed === 0) {
trailer = t("orchestration.tooltip.desc.subagentAllError");
} else if (errors > 0) {
trailer = t("orchestration.tooltip.desc.subagentSomeErrors", { count: errors });
}
}
return {
layer: t("orchestration.tooltip.layer.subagent"),
description: t("orchestration.tooltip.desc.subagent") + trailer,
};
}
case "nested":
return {
layer: t("orchestration.tooltip.layer.nested"),
description: t("orchestration.tooltip.desc.nested"),
};
case "outcome": {
const status = node.meta?.status ?? "completed";
const descKey =
status === "completed"
? "orchestration.tooltip.desc.outcomeCompleted"
: status === "error"
? "orchestration.tooltip.desc.outcomeError"
: status === "abandoned"
? "orchestration.tooltip.desc.outcomeAbandoned"
: "orchestration.tooltip.desc.outcomeFallback";
return {
layer: t("orchestration.tooltip.layer.outcome"),
description: t(descKey),
};
}
}
}
// ── Utility functions ─────────────────────────────────────────────────────────
function borderColorForKind(kind: DAGNode["kind"]): string {
switch (kind) {
case "session":
return "#6366f1";
case "main":
return "#3b82f6";
case "subagent":
return "#22c55e";
case "nested":
return "#14b8a6";
case "outcome":
return "#6366f1";
}
}
function textColorForKind(kind: DAGNode["kind"]): string {
switch (kind) {
case "session":
return "#a5b4fc";
case "main":
return "#93c5fd";
case "subagent":
return "#86efac";
case "nested":
return "#5eead4";
case "outcome":
return "#c4b5fd";
}
}
function badgeBgForKind(kind: DAGNode["kind"], status?: string): string {
if (kind === "outcome" && status) {
return outcomeColorSet(status).stroke + "33";
}
switch (kind) {
case "session":
return "rgba(99,102,241,0.25)";
case "main":
return "rgba(59,130,246,0.25)";
case "subagent":
return "rgba(34,197,94,0.25)";
case "nested":
return "rgba(20,184,166,0.25)";
case "outcome":
return "rgba(99,102,241,0.25)";
}
}
function fmtCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(Math.round(n));
}
// ── Legend data ───────────────────────────────────────────────────────────────
const LEGEND_ITEMS = [
{ label: "Sessions", color: "#312e81", border: "#6366f1" },
{ label: "Main Agent", color: "#1e3a5f", border: "#3b82f6" },
{ label: "Subagent Types", color: "#052e16", border: "#22c55e" },
{ label: "Compactions", color: "#134e4a", border: "#14b8a6" },
{ label: "Completed", color: "#052e16", border: "#16a34a" },
{ label: "Error", color: "#1f0808", border: "#dc2626" },
{ label: "Abandoned", color: "#1c1a04", border: "#ca8a04" },
];