/** * @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ĩ */ /* ============================================================================= * 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. * * ## 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; // ── 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 = { 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 ): { nodes: DAGNode[]; edges: DAGEdge[]; svgWidth: number; svgHeight: number; } { const rawNodes: Omit[] = []; // 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(); 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(); 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[][] = [[], [], [], [], []]; 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(nodes.map((n) => [n.id, n])); // Build edges from data.edges + synthetic structural edges const edgeSet = new Set(); 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(null); const containerRef = useRef(null); const tipRef = useRef(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("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 (

{t("orchestration.noData")}

{t("orchestration.noDataDesc")}

); } return (
{/* SVG DAG */}
{/* Legend */}
{t("orchestration.legend")} {LEGEND_ITEMS.map((item) => (
{item.label}
))}
{t("orchestration.edgeWeight")}
{/* Tooltip - fixed-positioned, DOM-mutated to avoid React re-renders on hover */} ); } // ── 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" }, ];