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.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
@@ -0,0 +1,643 @@
/**
* @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>
);
}
@@ -0,0 +1,429 @@
/**
* @file CompactionImpact.tsx
* @description Visualizes how context compaction is spread across sessions.
* Compaction is when Claude Code compresses older conversation history into a
* summary once a session's context window fills up. This panel surfaces the
* at-a-glance stats (total events, sessions affected, average and peak per
* session) and a histogram answering "how many sessions compacted N times?"
* so the distribution is legible regardless of how many sessions exist.
* @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
* - `CompactionImpactProps` — exported API; see TSDoc on the symbol for behavior.
* - `CompactionImpact` — 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).
* -----------------------------------------------------------------------------
* **CompactionImpactProps**
* 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.
*
* **CompactionImpact**
* 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, useState } from "react";
import { useTranslation } from "react-i18next";
import * as d3 from "d3";
import type { CompactionImpactData } from "../../lib/types";
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtTokens(n: number): string {
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
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(n);
}
/** Roll the per-session list into a histogram: for each compaction count k
* (1..peak), how many sessions compacted exactly k times. */
function toHistogram(perSession: CompactionImpactData["perSession"]): Array<{
count: number;
sessions: number;
}> {
const peak = perSession.reduce((m, s) => Math.max(m, s.compactions), 0);
const buckets: Array<{ count: number; sessions: number }> = [];
for (let k = 1; k <= peak; k++) {
buckets.push({ count: k, sessions: perSession.filter((s) => s.compactions === k).length });
}
return buckets;
}
// ── Chart constants ───────────────────────────────────────────────────────────
const MARGIN = { top: 18, right: 16, bottom: 46, left: 48 };
const CHART_HEIGHT = 200;
// ── D3 renderer ───────────────────────────────────────────────────────────────
interface HistogramBucket {
count: number;
sessions: number;
}
interface HistogramOpts {
x: string;
y: string;
onHover: (e: MouseEvent, d: HistogramBucket) => void;
onMove: (e: MouseEvent) => void;
onLeave: () => void;
}
function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: HistogramOpts): void {
const container = svg.parentElement;
const width = container ? container.clientWidth : 400;
const innerW = width - MARGIN.left - MARGIN.right;
const innerH = CHART_HEIGHT - MARGIN.top - MARGIN.bottom;
const root = d3.select(svg);
root.selectAll("*").remove();
root.attr("viewBox", `0 0 ${width} ${CHART_HEIGHT}`).attr("preserveAspectRatio", "xMidYMid meet");
const defs = root.append("defs");
const grad = defs
.append("linearGradient")
.attr("id", "compact-bar-grad")
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "0%")
.attr("y2", "100%");
grad.append("stop").attr("offset", "0%").attr("stop-color", "#818cf8");
grad
.append("stop")
.attr("offset", "100%")
.attr("stop-color", "#3730a3")
.attr("stop-opacity", 0.7);
const g = root.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
const maxSessions = d3.max(histo, (d) => d.sessions) ?? 1;
const xScale = d3
.scaleBand<number>()
.domain(histo.map((d) => d.count))
.range([0, innerW])
.padding(histo.length > 12 ? 0.18 : 0.34);
const yScale = d3.scaleLinear().domain([0, maxSessions]).nice().range([innerH, 0]);
// Horizontal grid lines (integer session counts)
const yTicks = yScale.ticks(Math.min(4, maxSessions)).filter((d) => Number.isInteger(d));
g.selectAll<SVGLineElement, number>(".grid-line")
.data(yTicks)
.join("line")
.attr("class", "grid-line")
.attr("x1", 0)
.attr("x2", innerW)
.attr("y1", (d) => yScale(d))
.attr("y2", (d) => yScale(d))
.attr("stroke", "#2a2a3d")
.attr("stroke-width", 1);
// Y axis (sessions)
g.append("g")
.call(
d3
.axisLeft(yScale)
.tickValues(yTicks)
.tickSize(0)
.tickPadding(8)
.tickFormat((d) => String(d))
)
.call((ax) => ax.select(".domain").remove())
.selectAll("text")
.attr("fill", "#6b7280")
.attr("font-size", 10)
.attr("font-family", "Inter, sans-serif");
// X axis (compactions per session - one tick per bucket)
g.append("g")
.attr("transform", `translate(0,${innerH})`)
.call(d3.axisBottom(xScale).tickSize(0).tickPadding(8))
.call((ax) => ax.select(".domain").remove())
.selectAll("text")
.attr("fill", "#9ca3af")
.attr("font-size", 10)
.attr("font-family", "Inter, sans-serif");
// Axis titles
g.append("text")
.attr("x", innerW / 2)
.attr("y", innerH + 38)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("font-size", 10)
.attr("font-weight", 500)
.attr("font-family", "Inter, sans-serif")
.text(opts.x);
g.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -innerH / 2)
.attr("y", -38)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("font-size", 10)
.attr("font-weight", 500)
.attr("font-family", "Inter, sans-serif")
.text(opts.y);
// Bars + count labels + rich hover tooltip (full-height hit-area so every
// column - including empty buckets - responds, matching the other charts).
histo.forEach((d) => {
const bx = xScale(d.count);
if (bx === undefined) return;
const bw = xScale.bandwidth();
const by = yScale(d.sessions);
const barH = innerH - by;
const bg = g.append("g");
let bar: d3.Selection<SVGRectElement, unknown, null, undefined> | null = null;
if (d.sessions > 0) {
bar = bg
.append("rect")
.attr("x", bx)
.attr("y", by)
.attr("width", bw)
.attr("height", barH)
.attr("rx", Math.min(4, bw / 2))
.attr("fill", "url(#compact-bar-grad)")
.style("transition", "fill 120ms ease");
bg.append("text")
.attr("x", bx + bw / 2)
.attr("y", by - 5)
.attr("text-anchor", "middle")
.attr("fill", "#a5b4fc")
.attr("font-size", 10)
.attr("font-weight", "600")
.attr("font-family", "Inter, sans-serif")
.attr("pointer-events", "none")
.text(d.sessions);
} else {
// Empty bucket: faint baseline tick so the gap reads as "zero", not missing
bg.append("rect")
.attr("x", bx)
.attr("y", innerH - 1)
.attr("width", bw)
.attr("height", 1)
.attr("fill", "#2a2a3d");
}
// Transparent, full-height hover target on top of the bar.
bg.append("rect")
.attr("x", bx)
.attr("y", 0)
.attr("width", bw)
.attr("height", innerH)
.attr("fill", "transparent")
.style("cursor", "pointer")
.on("mouseenter", (event: MouseEvent) => {
if (bar) bar.attr("fill", "#a5b4fc");
opts.onHover(event, d);
})
.on("mousemove", (event: MouseEvent) => opts.onMove(event))
.on("mouseleave", () => {
if (bar) bar.attr("fill", "url(#compact-bar-grad)");
opts.onLeave();
});
});
}
// ── Stat box ──────────────────────────────────────────────────────────────────
interface StatBoxProps {
label: string;
value: string;
sub?: string;
accent?: string;
}
function StatBox({ label, value, sub, accent = "text-accent" }: StatBoxProps) {
return (
<div className="flex flex-col gap-1 bg-surface-3 border border-border rounded-xl px-4 py-3.5 flex-1 min-w-0">
<span className={`text-2xl font-semibold tabular-nums ${accent}`}>{value}</span>
<span className="text-[11px] font-medium text-gray-500 uppercase tracking-wider leading-tight">
{label}
</span>
{sub && <span className="text-[11px] text-gray-600 tabular-nums">{sub}</span>}
</div>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
export interface CompactionImpactProps {
data: CompactionImpactData;
}
export function CompactionImpact({ data }: CompactionImpactProps) {
const { t } = useTranslation("workflows");
const svgRef = useRef<SVGSVGElement>(null);
const [tip, setTip] = useState<{ x: number; y: number; title: string; detail: string } | null>(
null
);
const hasData = data.totalCompactions > 0;
const affected = data.sessionsWithCompactions;
const sessionPct = data.totalSessions > 0 ? Math.round((affected / data.totalSessions) * 100) : 0;
const avgPerSession = affected > 0 ? data.totalCompactions / affected : 0;
const peak = data.perSession.reduce((m, s) => Math.max(m, s.compactions), 0);
useEffect(() => {
if (!svgRef.current || !hasData) return;
const histo = toHistogram(data.perSession);
const affectedN = data.sessionsWithCompactions;
renderHistogram(svgRef.current, histo, {
x: t("compaction.xAxis"),
y: t("compaction.yAxis"),
onHover: (e, d) => {
const pct = affectedN > 0 ? Math.round((d.sessions / affectedN) * 100) : 0;
setTip({
x: e.clientX,
y: e.clientY,
title: t("compaction.tipTitle", { count: d.count }),
detail: t("compaction.tipDetail", { sessions: d.sessions, pct }),
});
},
onMove: (e) => setTip((p) => (p ? { ...p, x: e.clientX, y: e.clientY } : p)),
onLeave: () => setTip(null),
});
}, [data, hasData, t]);
if (!hasData) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-500">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M9 17H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v3" />
<path d="M13 21l2-2 4 4" />
<path d="M17 21v-6" />
<path d="M21 17h-6" />
</svg>
<span className="text-sm">{t("compaction.noData")}</span>
</div>
);
}
return (
<div className="flex flex-col gap-5">
{/* What compaction is - one line so the numbers below make sense */}
<p className="text-xs text-gray-500 leading-relaxed">{t("compaction.help")}</p>
{/* Stat tiles */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatBox
label={t("compaction.totalCompactions")}
value={data.totalCompactions.toLocaleString()}
accent="text-accent-hover"
/>
<StatBox
label={t("compaction.sessionsAffected")}
value={affected.toLocaleString()}
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
accent="text-violet-300"
/>
<StatBox
label={t("compaction.avgPerSession")}
value={avgPerSession.toFixed(1)}
accent="text-blue-300"
/>
<StatBox
label={t("compaction.peakSession")}
value={peak.toLocaleString()}
accent="text-emerald-400"
/>
</div>
{/* Histogram: sessions by compaction count */}
<div className="w-full overflow-hidden">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
{t("compaction.distribution")}
</p>
<svg
ref={svgRef}
className="w-full"
style={{ height: CHART_HEIGHT }}
aria-label={t("compaction.ariaLabel")}
role="img"
/>
</div>
{/* Plain-English summary + (when present) tokens freed */}
<p className="text-xs text-gray-500 leading-relaxed">
{t("compaction.summary", {
affected: affected.toLocaleString(),
total: data.totalSessions.toLocaleString(),
pct: sessionPct,
})}
{data.tokensRecovered > 0 && (
<> {t("compaction.tokensFreed", { tokens: fmtTokens(data.tokensRecovered) })}</>
)}
</p>
{/* Hover tooltip (matches the app's other chart tooltips) */}
{tip && (
<div
className="fixed z-50 pointer-events-none rounded-md border border-[#2a2a4a] bg-[#12121f] px-2.5 py-1.5 text-xs shadow-xl"
style={{
left: tip.x > window.innerWidth - 220 ? tip.x - 14 : tip.x + 14,
top: tip.y - 10,
transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined,
}}
>
<div className="font-medium text-gray-100">{tip.title}</div>
<div className="mt-0.5 text-gray-400">{tip.detail}</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,370 @@
/**
* @file ConcurrencyTimeline.tsx
* @description Defines the ConcurrencyTimeline component that visualizes concurrency data for agent sessions using horizontal bars. Each lane represents an agent type (main or subagent) with the bar width proportional to the number of sessions and timing indicated as a percentage of the session duration. The component handles empty states gracefully and assigns distinct colors to different agent types 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`.
*
* ## Internal dependencies
* - `../../lib/types`
*
* ## Public surface
* - `ConcurrencyTimelineProps` — exported API; see TSDoc on the symbol for behavior.
* - `ConcurrencyTimeline` — exported API; see TSDoc on the symbol for behavior.
* - `laneColor` — 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).
* -----------------------------------------------------------------------------
* **ConcurrencyTimelineProps**
* 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.
*
* **ConcurrencyTimeline**
* 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.
*
* **laneColor**
* 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 { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { ConcurrencyData, ConcurrencyLane } from "../../lib/types";
// ── Color palette ─────────────────────────────────────────────────────────────
const MAIN_COLOR = "#6366f1"; // indigo
const SUBAGENT_PALETTE = [
"#10b981", // emerald
"#3b82f6", // blue
"#f59e0b", // amber
"#f43f5e", // rose
"#06b6d4", // cyan
"#f97316", // orange
"#a855f7", // purple
"#84cc16", // lime
];
// ── Lane row ──────────────────────────────────────────────────────────────────
interface LaneRowProps {
lane: ConcurrencyLane;
color: string;
maxCount: number;
onShowTip: (lane: ConcurrencyLane, color: string, anchor: HTMLElement) => void;
onHideTip: () => void;
}
type TFn = (key: string, options?: Record<string, unknown>) => string;
function describeLaneTiming(start: number, end: number, t: TFn): string {
// start/end are 01 fractions of session timeline.
const startPct = Math.round(start * 100);
const endPct = Math.round(end * 100);
const span = Math.max(0, endPct - startPct);
if (startPct < 15 && endPct > 85) return t("concurrency.tooltip.timing.wholeSession");
if (startPct < 15) return t("concurrency.tooltip.timing.frontLoadedFmt", { end: endPct });
if (endPct > 85) return t("concurrency.tooltip.timing.backLoadedFmt", { start: startPct });
if (span < 15) return t("concurrency.tooltip.timing.tightFmt", { start: startPct, end: endPct });
return t("concurrency.tooltip.timing.midSessionFmt", { start: startPct, end: endPct });
}
function LaneRow({ lane, color, maxCount, onShowTip, onHideTip }: LaneRowProps) {
const { t } = useTranslation("workflows");
const displayName = lane.name === "Main Agent" ? t("orchestration.mainAgent") : lane.name;
// Bar width proportional to session count (the metric with meaningful variance)
const barPct = maxCount > 0 ? (lane.count / maxCount) * 100 : 0;
// Duration as percentage of session (backend returns 0-1 fractions)
const startPct = (lane.avgStart * 100).toFixed(0);
const endPct = (lane.avgEnd * 100).toFixed(0);
return (
<div className="flex items-center gap-3 py-1.5 group">
{/* Label column */}
<div className="flex-shrink-0 w-[140px] text-right" title={displayName}>
<span className="text-xs font-medium text-gray-400 truncate block group-hover:text-gray-200 transition-colors">
{displayName}
</span>
</div>
{/* Bar area */}
<div
className="relative flex-1 h-6 bg-surface-3 rounded overflow-hidden"
onMouseEnter={(e) => onShowTip(lane, color, e.currentTarget)}
onMouseLeave={onHideTip}
>
<div
className="absolute top-0 bottom-0 left-0 rounded transition-all duration-300"
style={{
width: `${barPct}%`,
minWidth: barPct > 0 ? "4px" : undefined,
backgroundColor: color,
opacity: 0.85,
}}
/>
{/* Count label inside bar if wide enough, outside if not */}
<span
className="absolute top-0 bottom-0 flex items-center text-[11px] font-medium tabular-nums"
style={{
left: barPct > 15 ? "8px" : `calc(${barPct}% + 6px)`,
color: barPct > 15 ? "white" : "var(--color-gray-400)",
}}
>
{lane.count}
</span>
</div>
{/* Timing range */}
<div className="flex-shrink-0 w-[72px] text-[11px] text-gray-600 tabular-nums">
{startPct}%&ndash;{endPct}%
</div>
</div>
);
}
function buildLaneTooltip(
el: HTMLDivElement,
lane: ConcurrencyLane,
displayName: string,
color: string,
t: TFn
) {
while (el.firstChild) el.removeChild(el.firstChild);
const startPct = (lane.avgStart * 100).toFixed(0);
const endPct = (lane.avgEnd * 100).toFixed(0);
const header = document.createElement("div");
header.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:4px";
const dot = document.createElement("span");
dot.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:9999px;background:${color}`;
const title = document.createElement("p");
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0";
title.textContent = displayName;
header.appendChild(dot);
header.appendChild(title);
el.appendChild(header);
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("concurrency.tooltip.lane");
el.appendChild(subtitle);
const addRow = (label: string, value: string) => {
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);
el.appendChild(row);
};
addRow(t("concurrency.tooltip.sessionsWith"), String(lane.count));
addRow(t("concurrency.tooltip.avgStart"), `${startPct}%`);
addRow(t("concurrency.tooltip.avgEnd"), `${endPct}%`);
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:8px 0 0";
desc.textContent = describeLaneTiming(lane.avgStart, lane.avgEnd, t);
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("concurrency.tooltip.barHint");
el.appendChild(hint);
}
// ── Empty state ───────────────────────────────────────────────────────────────
function EmptyState() {
const { t } = useTranslation("workflows");
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
<svg
className="w-5 h-5 text-gray-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<rect x="3" y="4" width="18" height="4" rx="1" />
<rect x="3" y="10" width="12" height="4" rx="1" />
<rect x="3" y="16" width="15" height="4" rx="1" />
</svg>
</div>
<p className="text-sm font-medium text-gray-400">{t("concurrency.noData")}</p>
<p className="text-xs text-gray-600 mt-1">{t("concurrency.noDataDesc")}</p>
</div>
);
}
// ── Public component ──────────────────────────────────────────────────────────
export interface ConcurrencyTimelineProps {
data: ConcurrencyData;
}
export function ConcurrencyTimeline({ data }: ConcurrencyTimelineProps) {
const { t } = useTranslation("workflows");
const tipRef = useRef<HTMLDivElement>(null);
const lanes = data.aggregateLanes;
const hideTip = useCallback(() => {
const tip = tipRef.current;
if (tip) tip.style.opacity = "0";
}, []);
const showTip = useCallback(
(lane: ConcurrencyLane, color: string, anchor: HTMLElement) => {
const tip = tipRef.current;
if (!tip) return;
const displayName = lane.name === "Main Agent" ? t("orchestration.mainAgent") : lane.name;
buildLaneTooltip(tip, lane, displayName, color, t);
const r = anchor.getBoundingClientRect();
tip.style.opacity = "0";
tip.style.display = "block";
const tipW = tip.offsetWidth || 280;
const tipH = tip.offsetHeight || 160;
const margin = 8;
let left = r.left + r.width / 2 - tipW / 2;
if (left < margin) left = margin;
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
let top = r.top - tipH - 10;
if (top < margin) top = r.bottom + 10;
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
tip.style.opacity = "1";
},
[t]
);
if (lanes.length === 0) {
return <EmptyState />;
}
// Sort by session count descending so the most-used agent types are on top
const sorted = [...lanes].sort((a, b) => b.count - a.count);
const maxCount = sorted[0]?.count ?? 1;
// Assign colors
let subagentIndex = 0;
const coloredLanes = sorted.map((lane) => {
const isMain = lane.name === "Main Agent";
const color = isMain
? MAIN_COLOR
: (SUBAGENT_PALETTE[subagentIndex % SUBAGENT_PALETTE.length] ?? MAIN_COLOR);
if (!isMain) subagentIndex++;
return { lane, color };
});
return (
<div className="w-full" onMouseLeave={hideTip}>
{/* Header */}
<div className="flex items-center gap-3 mb-2">
<div className="flex-shrink-0 w-[140px]" />
<div className="flex-1 flex items-center justify-between">
<span className="text-[10px] text-gray-600 uppercase tracking-wider">
{t("concurrency.sessions")}
</span>
<span className="text-[10px] text-gray-600 tabular-nums">
{maxCount}
{t("concurrency.max")}
</span>
</div>
<div className="flex-shrink-0 w-[72px] text-[10px] text-gray-600 uppercase tracking-wider">
{t("concurrency.timing")}
</div>
</div>
{/* Lane rows */}
<div className="flex flex-col divide-y divide-surface-4">
{coloredLanes.map(({ lane, color }) => (
<LaneRow
key={lane.name}
lane={lane}
color={color}
maxCount={maxCount}
onShowTip={showTip}
onHideTip={hideTip}
/>
))}
</div>
<div
ref={tipRef}
role="tooltip"
aria-hidden="true"
className="fixed z-50 px-3 py-2 rounded-lg shadow-2xl pointer-events-none"
style={{
display: "none",
opacity: 0,
left: 0,
top: 0,
background: "#12121f",
border: "1px solid #2a2a4a",
color: "#e2e8f0",
minWidth: 240,
maxWidth: 320,
transition: "opacity 120ms ease-out",
}}
/>
</div>
);
}
// Re-export helper so callers can import the color fn if needed
export function laneColor(name: string, subagentIndex: number): string {
if (name === "Main Agent") return MAIN_COLOR;
return SUBAGENT_PALETTE[subagentIndex % SUBAGENT_PALETTE.length] ?? MAIN_COLOR;
}
@@ -0,0 +1,272 @@
/**
* @file ErrorPropagationMap.tsx
* @description A React component that visualizes error propagation across agent hierarchies in a workflow system. It displays the distribution of errors by hierarchy depth, identifies error-prone agent types, and highlights API and session errors. The component uses horizontal bars to represent error counts at different depths and types, providing an intuitive overview of where errors are occurring within the agent structure.
* @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
* - `ErrorPropagationMapProps` — exported API; see TSDoc on the symbol for behavior.
* - `ErrorPropagationMap` — 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).
* -----------------------------------------------------------------------------
* **ErrorPropagationMapProps**
* 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.
*
* **ErrorPropagationMap**
* 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 { useState } from "react";
import { useTranslation } from "react-i18next";
import type { ErrorPropagationData } from "../../lib/types";
const DEPTH_COLORS = ["#ef4444", "#f97316", "#eab308", "#a855f7"];
// ── Component ─────────────────────────────────────────────────────────────────
export interface ErrorPropagationMapProps {
data: ErrorPropagationData;
}
export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
const { t } = useTranslation("workflows");
const [hoveredDepth, setHoveredDepth] = useState<number | null>(null);
function depthLabel(depth: number): string {
const keys = [
t("errorPropagation.depthLabels.sessionMain"),
t("errorPropagation.depthLabels.directSubagent"),
t("errorPropagation.depthLabels.nested"),
t("errorPropagation.depthLabels.deep"),
];
return keys[depth] ?? `${t("common:depth")} ${depth}`;
}
const hasErrors =
data.byDepth.some((d) => d.count > 0) ||
data.byType.some((t) => t.count > 0) ||
(data.eventErrors && data.eventErrors.length > 0) ||
data.sessionsWithErrors > 0;
if (!hasErrors) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<div className="w-12 h-12 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#10b981"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
</div>
<span className="text-sm text-emerald-400 font-medium">
{t("errorPropagation.noErrors")}
</span>
<span className="text-xs text-gray-600">{t("errorPropagation.allSuccess")}</span>
</div>
);
}
const errorRatePct = data.errorRate;
const totalErrors = data.byDepth.reduce((s, d) => s + d.count, 0);
const maxDepthCount = Math.max(...data.byDepth.map((d) => d.count), 1);
const topTypes = [...data.byType].sort((a, b) => b.count - a.count).slice(0, 6);
const hasDepthData = data.byDepth.some((d) => d.count > 0);
return (
<div className="flex flex-col gap-4">
{/* Error rate summary bar */}
<div className="flex items-center gap-3 p-3 rounded-xl bg-red-500/5 border border-red-500/15">
<div className="flex-shrink-0 min-w-[2.75rem] h-10 px-2 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center justify-center">
<span className="text-[13px] font-bold text-red-400 tabular-nums whitespace-nowrap">
{errorRatePct}%
</span>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-red-300">
{t("errorPropagation.sessionsErrorSummary", {
errorSessions: data.sessionsWithErrors,
totalSessions: data.totalSessions,
})}
</p>
<p className="text-[11px] text-gray-500 mt-0.5">
{totalErrors > 0
? `${totalErrors}${t("errorPropagation.agentErrors")}`
: t("errorPropagation.sessionErrorsOnly")}
</p>
</div>
</div>
{/* Errors by depth - horizontal bars */}
{hasDepthData && (
<div>
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5">
{t("errorPropagation.errorsByDepth")}
</p>
<div className="flex flex-col gap-1.5">
{data.byDepth
.filter((d) => d.count > 0)
.map((d) => {
const pct = (d.count / maxDepthCount) * 100;
const color = DEPTH_COLORS[d.depth] ?? DEPTH_COLORS[DEPTH_COLORS.length - 1];
const isHovered = hoveredDepth === d.depth;
return (
<div
key={d.depth}
className="flex items-center gap-2.5 group"
onMouseEnter={() => setHoveredDepth(d.depth)}
onMouseLeave={() => setHoveredDepth(null)}
>
<span className="text-[11px] text-gray-500 w-24 flex-shrink-0 text-right truncate">
{depthLabel(d.depth)}
</span>
<div className="flex-1 h-5 bg-surface-3 rounded overflow-hidden relative">
<div
className="h-full rounded transition-all duration-300"
style={{
width: `${Math.max(pct, 4)}%`,
backgroundColor: color,
opacity: isHovered ? 1 : 0.75,
}}
/>
</div>
<span
className="text-xs font-semibold tabular-nums w-7 text-right transition-colors"
style={{ color: isHovered ? color : "#9ca3af" }}
>
{d.count}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Error-prone agent types */}
{topTypes.length > 0 && (
<div>
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5">
{t("errorPropagation.errorProneTypes")}
</p>
<div className="flex flex-col gap-1">
{topTypes.map((t, i) => {
const maxCount = topTypes[0]?.count ?? 1;
const pct = (t.count / maxCount) * 100;
return (
<div
key={t.subagent_type}
className="flex items-center gap-2.5 px-2.5 py-1.5 rounded-lg hover:bg-surface-3/50 transition-colors"
>
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: DEPTH_COLORS[Math.min(i, DEPTH_COLORS.length - 1)] }}
/>
<span className="text-xs text-gray-300 truncate flex-1 min-w-0">
{t.subagent_type}
</span>
<div className="w-16 h-1.5 bg-surface-4 rounded-full overflow-hidden flex-shrink-0">
<div
className="h-full rounded-full bg-red-400/60"
style={{ width: `${Math.max(pct, 8)}%` }}
/>
</div>
<span className="text-[11px] font-semibold text-red-300 tabular-nums w-5 text-right flex-shrink-0">
{t.count}
</span>
</div>
);
})}
</div>
</div>
)}
{/* API & session errors */}
{data.eventErrors && data.eventErrors.length > 0 && (
<div>
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5">
{t("errorPropagation.apiSessionErrors")}
</p>
<div className="flex flex-col gap-1">
{data.eventErrors.map((e) => (
<div
key={e.summary}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg bg-amber-500/5 border border-amber-500/10 hover:border-amber-500/20 transition-colors"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="#f59e0b"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span className="text-xs text-gray-300 truncate flex-1 min-w-0" title={e.summary}>
{e.summary}
</span>
<span className="flex-shrink-0 text-[11px] font-semibold text-amber-400 tabular-nums">
{e.count}x
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,537 @@
/**
* @file ModelDelegationFlow.tsx
* @description Defines the ModelDelegationFlow React component that visualizes the relationships between main models and subagent models in a flow diagram using D3.js. The component takes model delegation data as input and renders an SVG diagram that shows how different models are connected based on their usage in agents and sessions. It categorizes models into families (opus, sonnet, haiku, other) for color-coding and provides a clear visual representation of model delegation patterns.
* @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`
* - `../../lib/format`
*
* ## Public surface
* - `ModelDelegationFlowProps` — exported API; see TSDoc on the symbol for behavior.
* - `ModelDelegationFlow` — 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).
* -----------------------------------------------------------------------------
* **ModelDelegationFlowProps**
* 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.
*
* **ModelDelegationFlow**
* 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, useCallback } from "react";
import { useTranslation } from "react-i18next";
import * as d3 from "d3";
import type { ModelDelegationData } from "../../lib/types";
import { formatModelName } from "../../lib/format";
// ── Helpers ───────────────────────────────────────────────────────────────────
function modelFamily(name: string): "opus" | "sonnet" | "haiku" | "other" {
const lower = name.toLowerCase();
if (lower.includes("opus")) return "opus";
if (lower.includes("sonnet")) return "sonnet";
if (lower.includes("haiku")) return "haiku";
return "other";
}
function fmtTokens(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(0)}K`;
return String(n);
}
// Model formatting is handled by the shared formatModelName utility.
// ── Color palette per model family ───────────────────────────────────────────
const FAMILY_COLORS = {
opus: {
grad: ["#7c3aed", "#a855f7"] as [string, string],
stroke: "#a855f7",
text: "#e9d5ff",
badge: "rgba(168,85,247,0.15)",
},
sonnet: {
grad: ["#1d4ed8", "#3b82f6"] as [string, string],
stroke: "#3b82f6",
text: "#bfdbfe",
badge: "rgba(59,130,246,0.15)",
},
haiku: {
grad: ["#065f46", "#10b981"] as [string, string],
stroke: "#10b981",
text: "#a7f3d0",
badge: "rgba(16,185,129,0.15)",
},
other: {
grad: ["#374151", "#6b7280"] as [string, string],
stroke: "#6b7280",
text: "#d1d5db",
badge: "rgba(107,114,128,0.15)",
},
} as const;
// ── Types used internally ─────────────────────────────────────────────────────
interface NodeDatum {
id: string;
label: string;
family: "opus" | "sonnet" | "haiku" | "other";
agentCount: number;
sessionCount: number;
totalTokens: number;
side: "main" | "sub";
x: number;
y: number;
}
interface EdgeDatum {
sourceId: string;
targetId: string;
}
type ShowTipFn = (node: NodeDatum, anchor: SVGGraphicsElement) => void;
type HideTipFn = () => void;
// ── D3 chart renderer ─────────────────────────────────────────────────────────
const NODE_W = 160;
const NODE_H = 80;
const NODE_RX = 10;
const COL_GAP = 200;
const ROW_GAP = 108;
const PADDING = { top: 40, left: 24, right: 24, bottom: 24 };
function renderFlow(
svg: SVGSVGElement,
mainNodes: NodeDatum[],
subNodes: NodeDatum[],
edges: EdgeDatum[],
t: (key: string, options?: Record<string, unknown>) => string,
showTip: ShowTipFn,
hideTip: HideTipFn
): void {
const allNodes = [...mainNodes, ...subNodes];
const totalRows = Math.max(mainNodes.length, subNodes.length);
const chartH = totalRows * ROW_GAP + PADDING.top + PADDING.bottom;
const chartW = NODE_W * 2 + COL_GAP + PADDING.left + PADDING.right;
const root = d3.select(svg);
root.selectAll("*").remove();
root.attr("viewBox", `0 0 ${chartW} ${chartH}`).attr("preserveAspectRatio", "xMidYMid meet");
const defs = root.append("defs");
// Gradient defs per family
(["opus", "sonnet", "haiku", "other"] as const).forEach((fam) => {
const colors = FAMILY_COLORS[fam];
const grad = defs
.append("linearGradient")
.attr("id", `flow-grad-${fam}`)
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "100%");
grad.append("stop").attr("offset", "0%").attr("stop-color", colors.grad[0]);
grad.append("stop").attr("offset", "100%").attr("stop-color", colors.grad[1]);
});
const g = root.append("g");
// Column labels
const labelY = PADDING.top - 16;
g.append("text")
.attr("x", PADDING.left + NODE_W / 2)
.attr("y", labelY)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("font-size", 11)
.attr("font-family", "Inter, sans-serif")
.attr("letter-spacing", "0.08em")
.text(t("modelDelegation.mainModels"));
g.append("text")
.attr("x", PADDING.left + NODE_W + COL_GAP + NODE_W / 2)
.attr("y", labelY)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("font-size", 11)
.attr("font-family", "Inter, sans-serif")
.attr("letter-spacing", "0.08em")
.text(t("modelDelegation.subagentModels"));
// Build lookup for node positions
const nodeMap = new Map<string, NodeDatum>(allNodes.map((n) => [n.id, n]));
// Draw edges (cubic bezier curves)
edges.forEach(({ sourceId, targetId }) => {
const src = nodeMap.get(sourceId);
const tgt = nodeMap.get(targetId);
if (!src || !tgt) return;
const x1 = src.x + NODE_W;
const y1 = src.y + NODE_H / 2;
const x2 = tgt.x;
const y2 = tgt.y + NODE_H / 2;
const cx = (x1 + x2) / 2;
g.append("path")
.attr("d", `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`)
.attr("fill", "none")
.attr("stroke", "#2a2a3d")
.attr("stroke-width", 1.5)
.attr("opacity", 0.7);
});
// Draw nodes
allNodes.forEach((node) => {
const colors = FAMILY_COLORS[node.family];
const ng = g
.append("g")
.attr("transform", `translate(${node.x},${node.y})`)
.style("cursor", "default")
.on("mouseenter", function () {
showTip(node, this as SVGGraphicsElement);
})
.on("mouseleave", () => hideTip());
// Border glow rect (slightly larger)
ng.append("rect")
.attr("x", -1)
.attr("y", -1)
.attr("width", NODE_W + 2)
.attr("height", NODE_H + 2)
.attr("rx", NODE_RX + 1)
.attr("fill", "none")
.attr("stroke", colors.stroke)
.attr("stroke-width", 1)
.attr("opacity", 0.25);
// Main rect with gradient
ng.append("rect")
.attr("width", NODE_W)
.attr("height", NODE_H)
.attr("rx", NODE_RX)
.attr("fill", `url(#flow-grad-${node.family})`)
.attr("fill-opacity", 0.18)
.attr("stroke", colors.stroke)
.attr("stroke-width", 1);
// Model name
ng.append("text")
.attr("x", 12)
.attr("y", 22)
.attr("fill", colors.text)
.attr("font-size", 11)
.attr("font-weight", "600")
.attr("font-family", "Inter, sans-serif")
.text(formatModelName(node.label) ?? node.label);
// Agent count pill
ng.append("rect")
.attr("x", 10)
.attr("y", 32)
.attr("width", 64)
.attr("height", 16)
.attr("rx", 4)
.attr("fill", colors.badge);
ng.append("text")
.attr("x", 42)
.attr("y", 43.5)
.attr("text-anchor", "middle")
.attr("fill", colors.text)
.attr("font-size", 9.5)
.attr("font-family", "Inter, sans-serif")
.text(t("modelDelegation.agentsCount", { count: node.agentCount }));
// Token count
if (node.totalTokens > 0) {
ng.append("text")
.attr("x", 12)
.attr("y", 66)
.attr("fill", "#6b7280")
.attr("font-size", 9.5)
.attr("font-family", "Inter, sans-serif")
.text(t("modelDelegation.tokensCount", { tokens: fmtTokens(node.totalTokens) }));
}
// Session count (main nodes only)
if (node.side === "main" && node.sessionCount > 0) {
ng.append("text")
.attr("x", NODE_W - 10)
.attr("y", 66)
.attr("text-anchor", "end")
.attr("fill", "#6b7280")
.attr("font-size", 9.5)
.attr("font-family", "Inter, sans-serif")
.text(t("modelDelegation.sessionsCount", { count: node.sessionCount }));
}
});
}
// ── Component ─────────────────────────────────────────────────────────────────
export interface ModelDelegationFlowProps {
data: ModelDelegationData;
}
export function ModelDelegationFlow({ data }: ModelDelegationFlowProps) {
const { t } = useTranslation("workflows");
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tipRef = useRef<HTMLDivElement>(null);
const hasData = data.mainModels.length > 0 || data.subagentModels.length > 0;
const totalAgents = countTotalAgents(data);
const hideTip = useCallback(() => {
const tip = tipRef.current;
if (tip) tip.style.opacity = "0";
}, []);
const showTip = useCallback(
(node: NodeDatum, anchor: SVGGraphicsElement) => {
const tip = tipRef.current;
if (!tip) return;
buildModelDelegationTooltip(tip, node, totalAgents, t);
const r = anchor.getBoundingClientRect();
tip.style.opacity = "0";
tip.style.display = "block";
const tipW = tip.offsetWidth || 280;
const tipH = tip.offsetHeight || 160;
const margin = 8;
let left = r.left + r.width / 2 - tipW / 2;
if (left < margin) left = margin;
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
let top = r.top - tipH - 10;
if (top < margin) top = r.bottom + 10;
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
tip.style.opacity = "1";
},
[totalAgents, t]
);
useEffect(() => {
if (!svgRef.current || !hasData) return;
const tokenMap = new Map<string, number>();
data.tokensByModel.forEach(({ model, input_tokens, output_tokens }) => {
tokenMap.set(model, (tokenMap.get(model) ?? 0) + input_tokens + output_tokens);
});
const mainNodes: NodeDatum[] = data.mainModels.map((m, i) => ({
id: `main-${m.model}`,
label: formatModelName(m.model) ?? m.model,
family: modelFamily(m.model),
agentCount: m.agent_count,
sessionCount: m.session_count,
totalTokens: tokenMap.get(m.model) ?? 0,
side: "main",
x: PADDING.left,
y: PADDING.top + i * ROW_GAP,
}));
const subNodes: NodeDatum[] = data.subagentModels.map((m, i) => ({
id: `sub-${m.model}`,
label: formatModelName(m.model) ?? m.model,
family: modelFamily(m.model),
agentCount: m.agent_count,
sessionCount: 0,
totalTokens: tokenMap.get(m.model) ?? 0,
side: "sub",
x: PADDING.left + NODE_W + COL_GAP,
y: PADDING.top + i * ROW_GAP,
}));
// Connect all main models to all subagent models that share a family, or all if no match
const edges: EdgeDatum[] = [];
mainNodes.forEach((mn) => {
subNodes.forEach((sn) => {
edges.push({ sourceId: mn.id, targetId: sn.id });
});
});
renderFlow(svgRef.current, mainNodes, subNodes, edges, t, showTip, hideTip);
hideTip();
}, [data, hasData, t, showTip, hideTip]);
if (!hasData) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-500">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<span className="text-sm">{t("modelDelegation.noData")}</span>
</div>
);
}
return (
<div ref={containerRef} className="w-full overflow-x-auto relative" onMouseLeave={hideTip}>
<svg
ref={svgRef}
className="w-full"
style={{ minHeight: 120 }}
aria-label={t("modelDelegation.ariaLabel")}
role="img"
/>
<div
ref={tipRef}
role="tooltip"
aria-hidden="true"
className="fixed z-50 px-3 py-2 rounded-lg shadow-2xl pointer-events-none"
style={{
display: "none",
opacity: 0,
left: 0,
top: 0,
background: "#12121f",
border: "1px solid #2a2a4a",
color: "#e2e8f0",
minWidth: 240,
maxWidth: 320,
transition: "opacity 120ms ease-out",
}}
/>
</div>
);
}
// ── Tooltip helpers ───────────────────────────────────────────────────────────
function countTotalAgents(data: ModelDelegationData): number {
const mainSum = data.mainModels.reduce((s, m) => s + m.agent_count, 0);
const subSum = data.subagentModels.reduce((s, m) => s + m.agent_count, 0);
return mainSum + subSum;
}
type TFn = (key: string, options?: Record<string, unknown>) => string;
function describeFamily(family: NodeDatum["family"], t: TFn): string {
switch (family) {
case "opus":
return t("modelDelegation.tooltip.family.opus");
case "sonnet":
return t("modelDelegation.tooltip.family.sonnet");
case "haiku":
return t("modelDelegation.tooltip.family.haiku");
case "other":
return t("modelDelegation.tooltip.family.other");
}
}
function buildModelDelegationTooltip(
el: HTMLDivElement,
node: NodeDatum,
totalAgents: number,
t: TFn
) {
while (el.firstChild) el.removeChild(el.firstChild);
const sharePct = totalAgents > 0 ? `${((node.agentCount / totalAgents) * 100).toFixed(1)}%` : "-";
const sideLabel =
node.side === "main"
? t("modelDelegation.tooltip.mainModel")
: t("modelDelegation.tooltip.subagentModel");
const title = document.createElement("p");
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0";
title.textContent = node.label;
el.appendChild(title);
const subtitle = document.createElement("p");
subtitle.style.cssText =
"font-size:10px;color:#64748b;margin:2px 0 8px;text-transform:uppercase;letter-spacing:0.05em";
subtitle.textContent = `${sideLabel} · ${node.family}`;
el.appendChild(subtitle);
const addRow = (label: string, value: string) => {
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);
el.appendChild(row);
};
addRow(t("modelDelegation.tooltip.agentRuns"), node.agentCount.toLocaleString());
addRow(t("modelDelegation.tooltip.shareOfAll"), sharePct);
if (node.side === "main") {
addRow(t("modelDelegation.tooltip.sessionsOnModel"), String(node.sessionCount));
}
addRow(t("modelDelegation.tooltip.totalTokens"), node.totalTokens.toLocaleString());
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:8px 0 0";
desc.textContent = describeFamily(node.family, t);
el.appendChild(desc);
const hint = document.createElement("p");
hint.style.cssText = "font-size:11px;color:#64748b;line-height:1.45;margin:6px 0 0";
hint.textContent =
node.side === "main"
? t("modelDelegation.tooltip.lines.main")
: t("modelDelegation.tooltip.lines.sub");
el.appendChild(hint);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,399 @@
/**
* @file SessionComplexityScatter.tsx
* @description A React component that renders a scatter plot visualization of session complexity using D3.js. Each session is represented as a bubble, where the x-axis represents the session duration, the y-axis represents the number of agents involved, and the size of the bubble corresponds to the total tokens used. The color of each bubble indicates the session status (e.g., completed, active, error, abandoned). The component also includes tooltips for detailed information on hover and a legend for status colors. It is designed to be responsive and provides an empty state when no data is available.
* @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`
* - `../../lib/format`
*
* ## Public surface
* - `SessionComplexityScatterProps` — exported API; see TSDoc on the symbol for behavior.
* - `SessionComplexityScatter` — 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).
* -----------------------------------------------------------------------------
* **SessionComplexityScatterProps**
* 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.
*
* **SessionComplexityScatter**
* 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, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import * as d3 from "d3";
import type { SessionComplexityItem } from "../../lib/types";
import { formatModelName } from "../../lib/format";
// ── Constants ─────────────────────────────────────────────────────────────────
const MARGIN = { top: 20, right: 24, bottom: 60, left: 52 };
const MIN_BUBBLE_R = 4;
const MAX_BUBBLE_R = 32;
const STATUS_COLOR: Record<string, string> = {
completed: "#22c55e",
error: "#ef4444",
active: "#6366f1",
abandoned: "#eab308",
};
function statusColor(status: string): string {
return STATUS_COLOR[status] ?? "#6b7280";
}
// ── Duration formatting ───────────────────────────────────────────────────────
function formatDurationSec(sec: number): string {
if (sec < 60) return `${Math.round(sec)}s`;
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
const s = Math.round(sec % 60);
return s > 0 ? `${m}m ${s}s` : `${m}m`;
}
function fmtXTick(sec: number): string {
if (sec === 0) return "0";
if (sec < 60) return `${Math.round(sec)}s`;
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
if (h > 0) return `${h}h ${m > 0 ? `${m}m` : ""}`;
return `${m}m`;
}
function fmtTokens(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(0)}K`;
return String(n);
}
// ── Tooltip ───────────────────────────────────────────────────────────────────
interface TooltipState {
x: number;
y: number;
item: SessionComplexityItem;
}
function Tooltip({ state }: { state: TooltipState }) {
const { t } = useTranslation("workflows");
const nearRight = state.x > window.innerWidth - 220;
return (
<div
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-gray-200 pointer-events-none whitespace-nowrap"
style={{
left: nearRight ? state.x - 12 : state.x + 12,
top: state.y - 10,
transform: nearRight ? "translateX(-100%)" : undefined,
}}
>
<p className="font-semibold text-gray-100 mb-1 truncate max-w-[180px]">
{state.item.name ?? state.item.id.slice(0, 12)}
</p>
<div className="flex flex-col gap-0.5 text-gray-400">
<span>
{t("complexity.tooltip.duration")} {formatDurationSec(state.item.duration)}
</span>
<span>
{t("complexity.tooltip.agents")} {state.item.agentCount}
</span>
<span>
{t("complexity.tooltip.subagents")} {state.item.subagentCount}
</span>
<span>
{t("complexity.tooltip.tokens")} {fmtTokens(state.item.totalTokens)}
</span>
{state.item.model && (
<span>
{t("complexity.tooltip.model")} {formatModelName(state.item.model)}
</span>
)}
</div>
<div className="mt-1 pt-1 border-t border-[#2a2a4a]">
<span className="font-medium" style={{ color: statusColor(state.item.status) }}>
{t(`common:status.${state.item.status}`, { defaultValue: state.item.status })}
</span>
</div>
</div>
);
}
// ── Legend ────────────────────────────────────────────────────────────────────
const LEGEND_STATUSES = ["completed", "active", "error", "abandoned"] as const;
function Legend() {
const { t } = useTranslation("workflows");
return (
<div className="flex flex-wrap items-center gap-4 justify-center mt-2">
{LEGEND_STATUSES.map((s) => (
<div key={s} className="flex items-center gap-1.5">
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: statusColor(s) }}
/>
<span className="text-xs text-gray-500">
{t(`common:status.${s}`, { defaultValue: s })}
</span>
</div>
))}
</div>
);
}
// ── Empty state ───────────────────────────────────────────────────────────────
function EmptyState() {
const { t } = useTranslation("workflows");
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
<svg
className="w-5 h-5 text-gray-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<circle cx="7" cy="12" r="3" />
<circle cx="17" cy="8" r="2" />
<circle cx="14" cy="17" r="4" />
</svg>
</div>
<p className="text-sm font-medium text-gray-400">{t("complexity.noData")}</p>
<p className="text-xs text-gray-600 mt-1">{t("complexity.noDataDesc")}</p>
</div>
);
}
// ── Main chart ────────────────────────────────────────────────────────────────
export interface SessionComplexityScatterProps {
data: SessionComplexityItem[];
onSessionClick?: (id: string) => void;
}
export function SessionComplexityScatter({ data, onSessionClick }: SessionComplexityScatterProps) {
const { t } = useTranslation("workflows");
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [width, setWidth] = useState(600);
// Track container width for responsiveness
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const obs = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w) setWidth(w);
});
obs.observe(el);
setWidth(el.clientWidth);
return () => obs.disconnect();
}, []);
const height = Math.max(280, Math.min(420, width * 0.5));
const handleMouseLeave = useCallback(() => setTooltip(null), []);
useEffect(() => {
if (!svgRef.current || data.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const innerW = width - MARGIN.left - MARGIN.right;
const innerH = height - MARGIN.top - MARGIN.bottom;
const g = svg.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
// Scales
const xScale = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.duration) ?? 1])
.nice()
.range([0, innerW]);
const yScale = d3
.scaleLinear()
.domain([0, (d3.max(data, (d) => d.agentCount) ?? 1) + 1])
.nice()
.range([innerH, 0]);
const rScale = d3
.scaleSqrt()
.domain([0, d3.max(data, (d) => d.totalTokens) ?? 1])
.range([MIN_BUBBLE_R, MAX_BUBBLE_R]);
// Grid lines
const gridColor = "#2a2a3d";
g.append("g")
.attr("class", "grid-x")
.call(
d3
.axisBottom(xScale)
.ticks(5)
.tickSize(-innerH)
.tickFormat(() => "")
)
.attr("transform", `translate(0,${innerH})`)
.call((sel) => {
sel.select(".domain").remove();
sel.selectAll(".tick line").attr("stroke", gridColor).attr("stroke-dasharray", "3,3");
});
g.append("g")
.attr("class", "grid-y")
.call(
d3
.axisLeft(yScale)
.ticks(5)
.tickSize(-innerW)
.tickFormat(() => "")
)
.call((sel) => {
sel.select(".domain").remove();
sel.selectAll(".tick line").attr("stroke", gridColor).attr("stroke-dasharray", "3,3");
});
// X axis
g.append("g")
.attr("transform", `translate(0,${innerH})`)
.call(
d3
.axisBottom(xScale)
.ticks(5)
.tickFormat((d) => fmtXTick(d as number))
)
.call((sel) => {
sel.select(".domain").attr("stroke", "#363650");
sel.selectAll(".tick line").attr("stroke", "#363650");
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
});
// X axis label
g.append("text")
.attr("x", innerW / 2)
.attr("y", innerH + 44)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("font-size", "11")
.text(t("complexity.duration"));
// Y axis
g.append("g")
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format("d")))
.call((sel) => {
sel.select(".domain").attr("stroke", "#363650");
sel.selectAll(".tick line").attr("stroke", "#363650");
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
});
// Y axis label
g.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -innerH / 2)
.attr("y", -40)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("font-size", "11")
.text(t("complexity.agentCount"));
// Bubbles - sort largest to back so small ones are clickable
const sorted = [...data].sort((a, b) => b.totalTokens - a.totalTokens);
g.selectAll<SVGCircleElement, SessionComplexityItem>("circle")
.data(sorted)
.join("circle")
.attr("cx", (d) => xScale(d.duration))
.attr("cy", (d) => yScale(d.agentCount))
.attr("r", (d) => rScale(d.totalTokens))
.attr("fill", (d) => statusColor(d.status))
.attr("fill-opacity", 0.75)
.attr("stroke", (d) => statusColor(d.status))
.attr("stroke-width", 1.5)
.attr("stroke-opacity", 0.9)
.style("cursor", onSessionClick ? "pointer" : "default")
.on("mouseenter", (event: MouseEvent, d) => {
d3.select(event.currentTarget as SVGCircleElement)
.attr("fill-opacity", 1)
.attr("stroke-width", 2.5);
setTooltip({ x: event.clientX, y: event.clientY, item: d });
})
.on("mousemove", (event: MouseEvent) => {
setTooltip((prev) => (prev ? { ...prev, x: event.clientX, y: event.clientY } : null));
})
.on("mouseleave", (event: MouseEvent) => {
d3.select(event.currentTarget as SVGCircleElement)
.attr("fill-opacity", 0.75)
.attr("stroke-width", 1.5);
setTooltip(null);
})
.on("click", (_event: MouseEvent, d) => {
onSessionClick?.(d.id);
});
}, [data, width, height, onSessionClick, t]);
if (data.length === 0) return <EmptyState />;
return (
<div
ref={containerRef}
className="w-full h-full flex flex-col justify-center"
onMouseLeave={handleMouseLeave}
>
<svg
ref={svgRef}
width={width}
height={height}
aria-label={t("complexity.ariaLabel")}
role="img"
/>
<Legend />
{tooltip && <Tooltip state={tooltip} />}
</div>
);
}
@@ -0,0 +1,773 @@
/**
* @file SessionDrillIn.tsx
* @description Defines the SessionDrillIn component, which provides a detailed view of a specific session in the agent dashboard application. It allows users to drill into the agent tree, tool timeline, and event sequence for a selected session. The component manages its own state for loading, error handling, and active tab selection, and it fetches the necessary data from the backend API when a session is selected. It also includes a session selector for searching and selecting different sessions to view.
* @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/api`
* - `../../lib/format`
* - `../../lib/types`
*
* ## Public surface
* - `SessionDrillInProps` — exported API; see TSDoc on the symbol for behavior.
* - `SessionDrillIn` — 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).
* -----------------------------------------------------------------------------
* **SessionDrillInProps**
* 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.
*
* **SessionDrillIn**
* 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 { useState, useEffect, useRef, useCallback } from "react";
import { X, GitFork, Wrench, List, Search, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import { formatDateTime, formatMs, formatModelName } from "../../lib/format";
import type {
SessionDrillIn as SessionDrillInData,
DashboardEvent,
Session,
} from "../../lib/types";
// ── Types ─────────────────────────────────────────────────────────────────────
type Tab = "tree" | "timeline" | "events";
type AgentNode = SessionDrillInData["tree"][number];
// ── Helpers ───────────────────────────────────────────────────────────────────
function statusColor(status: string): string {
switch (status) {
case "completed":
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
case "working":
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
case "error":
return "text-red-400 bg-red-500/10 border-red-500/20";
case "active":
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
case "waiting":
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
default:
return "text-gray-400 bg-gray-500/10 border-gray-500/20";
}
}
function safeTimestamp(raw: string): string {
try {
const normalized = /[Zz]$|[+-]\d{2}:\d{2}$/.test(raw) ? raw : raw.replace(" ", "T") + "Z";
return formatDateTime(normalized);
} catch {
return raw;
}
}
// ── Tab bar ───────────────────────────────────────────────────────────────────
interface TabBarProps {
active: Tab;
onChange: (t: Tab) => void;
}
function TabBar({ active, onChange }: TabBarProps) {
const { t } = useTranslation("workflows");
const tabs = [
{
id: "tree" as Tab,
label: t("drillIn.tabs.agentTree"),
icon: <GitFork className="w-3.5 h-3.5" />,
},
{
id: "timeline" as Tab,
label: t("drillIn.tabs.toolTimeline"),
icon: <Wrench className="w-3.5 h-3.5" />,
},
{
id: "events" as Tab,
label: t("drillIn.tabs.eventSequence"),
icon: <List className="w-3.5 h-3.5" />,
},
];
return (
<div className="flex gap-1 p-1 bg-surface-3 rounded-lg">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={[
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors duration-150",
active === tab.id
? "bg-surface-5 text-gray-100 shadow-sm"
: "text-gray-500 hover:text-gray-300",
].join(" ")}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
);
}
// ── Agent Tree ────────────────────────────────────────────────────────────────
interface TreeNodeProps {
node: AgentNode;
depth: number;
}
function TreeNode({ node, depth }: TreeNodeProps) {
const { t } = useTranslation(["workflows", "common"]);
const indentPx = depth * 20;
const isMain = node.type === "main";
const dur = node.ended_at
? formatMs(
Math.max(
0,
new Date(node.ended_at + "Z").getTime() - new Date(node.started_at + "Z").getTime()
)
)
: t("common:running");
const sc = statusColor(node.status);
const statusLabel = t(`common:status.${node.status}`, { defaultValue: node.status });
return (
<div>
<div
className="flex items-center gap-2 py-1.5 hover:bg-white/5 rounded transition-colors"
style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }}
>
{/* Depth connector line */}
{depth > 0 && <span className="w-px h-4 bg-border flex-shrink-0 -ml-3 mr-1" aria-hidden />}
{/* Status badge */}
<span
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
>
{statusLabel}
</span>
{/* Name */}
<span
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-gray-200"}`}
>
{node.name}
</span>
{/* Subagent type */}
{node.subagent_type && (
<span className="text-xs text-gray-500 truncate flex-shrink-0">
[{node.subagent_type}]
</span>
)}
{/* Duration */}
<span className="ml-auto flex-shrink-0 text-xs text-gray-600 tabular-nums">{dur}</span>
</div>
{node.children.length > 0 && (
<div>
{node.children.map((child) => (
<TreeNode key={child.id} node={child} depth={depth + 1} />
))}
</div>
)}
</div>
);
}
interface AgentTreeProps {
tree: SessionDrillInData["tree"];
}
function AgentTree({ tree }: AgentTreeProps) {
const { t } = useTranslation("workflows");
if (tree.length === 0) {
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noAgentTree")}</p>;
}
return (
<div className="overflow-auto max-h-[420px] pr-1">
{tree.map((node) => (
<TreeNode key={node.id} node={node} depth={0} />
))}
</div>
);
}
// ── Tool Timeline ─────────────────────────────────────────────────────────────
type ToolEvent = SessionDrillInData["toolTimeline"][number];
interface ToolTimelineProps {
events: ToolEvent[];
}
function ToolTimeline({ events }: ToolTimelineProps) {
const { t } = useTranslation("workflows");
if (events.length === 0) {
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noToolEvents")}</p>;
}
return (
<div className="overflow-x-auto max-h-[420px] overflow-y-auto">
<div className="flex flex-col gap-1 min-w-0">
{events.map((ev) => (
<div
key={ev.id}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors"
>
{/* Tool pill */}
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
{ev.tool_name ?? ev.event_type}
</span>
{/* Summary */}
{ev.summary && (
<span className="text-xs text-gray-400 truncate flex-1 min-w-0">{ev.summary}</span>
)}
{/* Timestamp */}
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums ml-auto">
{safeTimestamp(ev.created_at)}
</span>
</div>
))}
</div>
</div>
);
}
// ── Event Sequence ────────────────────────────────────────────────────────────
interface EventSequenceProps {
events: DashboardEvent[];
}
const EVENT_TYPE_COLOR: Record<string, string> = {
tool_use: "text-blue-400",
tool_result: "text-emerald-400",
agent_start: "text-indigo-400",
agent_stop: "text-violet-400",
compaction: "text-amber-400",
error: "text-red-400",
};
function eventTypeColor(type: string): string {
return EVENT_TYPE_COLOR[type] ?? "text-gray-400";
}
function EventSequence({ events }: EventSequenceProps) {
const { t } = useTranslation("workflows");
if (events.length === 0) {
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noEvents")}</p>;
}
const recent = events.slice(0, 100);
return (
<div className="overflow-auto max-h-[420px]">
<div className="flex flex-col gap-0.5">
{recent.map((ev) => (
<div
key={ev.id}
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors group"
>
{/* Event type badge */}
<span
className={`flex-shrink-0 text-[10px] font-semibold uppercase tracking-wide mt-0.5 w-[90px] truncate ${eventTypeColor(ev.event_type)}`}
title={ev.event_type}
>
{ev.event_type}
</span>
{/* Summary */}
<span className="text-xs text-gray-400 flex-1 min-w-0 truncate">
{ev.summary ?? ev.tool_name ?? "-"}
</span>
{/* Timestamp */}
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
{safeTimestamp(ev.created_at)}
</span>
</div>
))}
{events.length > 100 && (
<p className="text-xs text-gray-600 text-center py-2">
{t("drillIn.showingOf", { total: events.length })}
</p>
)}
</div>
</div>
);
}
// ── Loading / Error states ────────────────────────────────────────────────────
function LoadingState() {
return (
<div className="flex flex-col gap-3 py-8 px-4 animate-pulse">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-4 bg-surface-4 rounded" style={{ width: `${80 - i * 10}%` }} />
))}
</div>
);
}
interface ErrorStateProps {
message: string;
}
function ErrorState({ message }: ErrorStateProps) {
const { t } = useTranslation("workflows");
return (
<div className="flex flex-col items-center justify-center py-10 text-center px-4">
<div className="w-9 h-9 rounded-xl bg-red-500/10 border border-red-500/20 flex items-center justify-center mb-3">
<X className="w-4 h-4 text-red-400" />
</div>
<p className="text-sm font-medium text-red-400">{t("drillIn.failedLoad")}</p>
<p className="text-xs text-gray-600 mt-1 max-w-xs">{message}</p>
</div>
);
}
// ── Empty / no-selection state ────────────────────────────────────────────────
interface NoSessionStateProps {
onSelectSession: (id: string) => void;
}
function NoSessionState({ onSelectSession }: NoSessionStateProps) {
const { t } = useTranslation("workflows");
const tabs = [
{
id: "tree" as Tab,
label: t("drillIn.tabs.agentTree"),
icon: <GitFork className="w-3.5 h-3.5" />,
},
{
id: "timeline" as Tab,
label: t("drillIn.tabs.toolTimeline"),
icon: <Wrench className="w-3.5 h-3.5" />,
},
{
id: "events" as Tab,
label: t("drillIn.tabs.eventSequence"),
icon: <List className="w-3.5 h-3.5" />,
},
];
return (
<div className="flex flex-col py-6 px-4 border-2 border-dashed border-border rounded-xl">
<SessionSelector onSelectSession={onSelectSession} />
<div className="flex flex-col items-center text-center mt-2">
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-4">
<GitFork className="w-5 h-5 text-gray-600" />
</div>
<p className="text-sm font-medium text-gray-400 mb-1">{t("drillIn.noSessionSelected")}</p>
<p className="text-xs text-gray-600 max-w-xs">{t("drillIn.noSessionDesc")}</p>
{/* Preview tab pills */}
<div className="flex gap-2 mt-5">
{tabs.map((tab) => (
<div
key={tab.id}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-gray-600 bg-surface-3 border border-border"
>
{tab.icon}
{tab.label}
</div>
))}
</div>
</div>
</div>
);
}
// ── Session header ────────────────────────────────────────────────────────────
interface SessionHeaderProps {
drillIn: SessionDrillInData;
onClose: () => void;
activeTab: Tab;
onTabChange: (t: Tab) => void;
}
function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHeaderProps) {
const { t } = useTranslation("workflows");
const { session } = drillIn;
return (
<div className="flex flex-col gap-3 mb-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-100 truncate">
{session.name ?? session.id}
</p>
<p className="text-xs text-gray-500 mt-0.5 truncate">
{formatModelName(session.model) ?? t("drillIn.unknownModel")} &middot;{" "}
{t(`common:status.${session.status}`, { defaultValue: session.status })}
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
</p>
</div>
<button
type="button"
onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-gray-500 hover:text-gray-200 hover:bg-white/10 transition-colors"
aria-label={t("drillIn.closePanel")}
>
<X className="w-4 h-4" />
</button>
</div>
<TabBar active={activeTab} onChange={onTabChange} />
</div>
);
}
// ── Session Selector ──────────────────────────────────────────────────────────
const PAGE_SIZE = 20;
interface SessionSelectorProps {
onSelectSession: (id: string) => void;
}
function SessionSelector({ onSelectSession }: SessionSelectorProps) {
const { t } = useTranslation("workflows");
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [sessions, setSessions] = useState<Session[]>([]);
const [allSessions, setAllSessions] = useState<Session[]>([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [allLoaded, setAllLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const fetchPage = useCallback((pageOffset: number, replace: boolean) => {
setLoading(true);
api.sessions
.list({ limit: PAGE_SIZE, offset: pageOffset })
.then(({ sessions: page }) => {
setSessions((prev) => (replace ? page : [...prev, ...page]));
setHasMore(page.length === PAGE_SIZE);
setOffset(pageOffset + page.length);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
// Load ALL sessions for search (once, lazily)
const loadAllSessions = useCallback(() => {
if (allLoaded) return;
setAllLoaded(true);
// Fetch large batch for search
api.sessions
.list({ limit: 5000, offset: 0 })
.then(({ sessions: all }) => setAllSessions(all))
.catch(() => {});
}, [allLoaded]);
// Load first page when dropdown opens
useEffect(() => {
if (open && sessions.length === 0) {
fetchPage(0, true);
}
}, [open, sessions.length, fetchPage]);
// When user starts typing, load all sessions for search
useEffect(() => {
if (search.trim().length > 0) {
loadAllSessions();
}
}, [search, loadAllSessions]);
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// When searching, filter across ALL sessions; otherwise show paginated
const filtered = search.trim()
? (allSessions.length > 0 ? allSessions : sessions).filter((s) => {
const q = search.toLowerCase();
return (s.name ?? "").toLowerCase().includes(q) || s.id.toLowerCase().includes(q);
})
: sessions;
function handleSelect(id: string) {
setOpen(false);
setSearch("");
onSelectSession(id);
}
function handleLoadMore() {
fetchPage(offset, false);
}
return (
<div ref={containerRef} className="relative mb-4">
{/* Trigger row */}
<div
className={[
"flex items-center gap-2 px-3 py-2 rounded-lg border bg-surface-3 transition-colors cursor-text",
open ? "border-indigo-500/40 ring-1 ring-indigo-500/20" : "border-border",
].join(" ")}
onClick={() => {
setOpen(true);
inputRef.current?.focus();
}}
>
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
<input
ref={inputRef}
type="text"
value={search}
placeholder={t("drillIn.searchPlaceholder")}
className="flex-1 bg-transparent text-xs text-gray-200 placeholder-gray-600 outline-none min-w-0"
onFocus={() => setOpen(true)}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
/>
<ChevronDown
className={[
"w-3.5 h-3.5 text-gray-600 flex-shrink-0 transition-transform duration-150",
open ? "rotate-180" : "",
].join(" ")}
/>
</div>
{/* Dropdown panel */}
{open && (
<div className="absolute z-50 left-0 right-0 top-full mt-1 bg-surface-2 border border-border rounded-lg shadow-xl overflow-hidden">
<div className="max-h-64 overflow-y-auto">
{loading && sessions.length === 0 ? (
<div className="flex flex-col gap-2 p-3 animate-pulse">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="h-3 bg-surface-4 rounded"
style={{ width: `${75 - i * 8}%` }}
/>
))}
</div>
) : filtered.length === 0 ? (
<p className="text-xs text-gray-600 text-center py-6 px-3">
{search.trim() ? t("drillIn.noMatch") : t("drillIn.notFound")}
</p>
) : (
<div className="flex flex-col">
{filtered.map((s) => {
const sc = statusColor(s.status);
return (
<button
key={s.id}
type="button"
onClick={() => handleSelect(s.id)}
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors border-b border-border/50 last:border-0"
>
<span
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
>
{s.status}
</span>
<span className="flex-1 min-w-0">
<span className="block text-xs font-medium text-gray-200 truncate">
{s.name ?? s.id}
</span>
{s.name && (
<span className="block text-[10px] text-gray-600 font-mono truncate">
{s.id}
</span>
)}
</span>
{s.model && (
<span className="flex-shrink-0 text-[10px] text-gray-500 truncate max-w-[80px]">
{formatModelName(s.model)}
</span>
)}
{s.started_at && (
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums">
{safeTimestamp(s.started_at)}
</span>
)}
</button>
);
})}
</div>
)}
{/* Load more - only show when not filtering client-side */}
{!search.trim() && hasMore && (
<button
type="button"
onClick={handleLoadMore}
disabled={loading}
className="w-full px-3 py-2 text-xs text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
>
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
</button>
)}
</div>
</div>
)}
</div>
);
}
// ── Public component ──────────────────────────────────────────────────────────
export interface SessionDrillInProps {
sessionId: string | null;
onClose: () => void;
onSelectSession: (id: string) => void;
}
export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionDrillInProps) {
const { t } = useTranslation(["workflows", "common"]);
const [activeTab, setActiveTab] = useState<Tab>("tree");
const [drillIn, setDrillIn] = useState<SessionDrillInData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!sessionId) {
setDrillIn(null);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setDrillIn(null);
api.workflows
.session(sessionId)
.then((data) => {
if (!cancelled) {
setDrillIn(data);
setActiveTab("tree");
}
})
.catch((err: unknown) => {
if (!cancelled) {
const msg = err instanceof Error ? err.message : t("common:unexpectedError");
setError(msg);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [sessionId, t]);
// No session selected
if (!sessionId) {
return <NoSessionState onSelectSession={onSelectSession} />;
}
if (loading) {
return (
<div className="bg-surface-2 border border-border rounded-xl p-4">
<SessionSelector onSelectSession={onSelectSession} />
<LoadingState />
</div>
);
}
if (error) {
return (
<div className="bg-surface-2 border border-border rounded-xl p-4">
<SessionSelector onSelectSession={onSelectSession} />
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-600 font-mono truncate">{sessionId}</p>
<button
type="button"
onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded text-gray-600 hover:text-gray-300 hover:bg-white/10 transition-colors"
aria-label={t("drillIn.close")}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<ErrorState message={error} />
</div>
);
}
if (!drillIn) return null;
return (
<div className="bg-surface-2 border border-border rounded-xl p-4 animate-fade-in">
<SessionSelector onSelectSession={onSelectSession} />
<SessionHeader
drillIn={drillIn}
onClose={onClose}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Tab content */}
{activeTab === "tree" && <AgentTree tree={drillIn.tree} />}
{activeTab === "timeline" && <ToolTimeline events={drillIn.toolTimeline} />}
{activeTab === "events" && <EventSequence events={drillIn.events} />}
</div>
);
}
@@ -0,0 +1,391 @@
/**
* @file SubagentEffectiveness.tsx
* @description Defines the SubagentEffectiveness React component that visualizes the effectiveness of subagents in a workflow. It displays a success rate as a circular progress ring, key metrics such as total sessions and average duration, and a sparkline showing weekly activity trends. The component is designed to handle cases with no data gracefully and uses a consistent color scheme 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`.
*
* ## Internal dependencies
* - `../../lib/types`
*
* ## Public surface
* - `SubagentEffectivenessProps` — exported API; see TSDoc on the symbol for behavior.
* - `SubagentEffectiveness` — 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).
* -----------------------------------------------------------------------------
* **SubagentEffectivenessProps**
* 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.
*
* **SubagentEffectiveness**
* 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 { useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import type { SubagentEffectivenessItem } from "../../lib/types";
const COLORS = [
"#10b981",
"#3b82f6",
"#a855f7",
"#f59e0b",
"#f43f5e",
"#06b6d4",
"#f97316",
"#6366f1",
] as const;
const RING_RADIUS = 28;
const RING_STROKE = 5;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
function formatDurationSec(seconds: number | null): string {
if (seconds === null || seconds < 0) return "-";
const totalSec = Math.floor(seconds);
const hours = Math.floor(totalSec / 3600);
const minutes = Math.floor((totalSec % 3600) / 60);
const secs = totalSec % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
interface SuccessRingProps {
rate: number;
color: string;
}
function SuccessRing({ rate, color }: SuccessRingProps) {
const { t } = useTranslation("workflows");
const clampedRate = Math.max(0, Math.min(100, rate));
const filled = (clampedRate / 100) * RING_CIRCUMFERENCE;
const gap = RING_CIRCUMFERENCE - filled;
const viewSize = (RING_RADIUS + RING_STROKE) * 2 + 4;
const center = viewSize / 2;
return (
<div className="flex flex-col items-center gap-2">
<svg
width={viewSize}
height={viewSize}
viewBox={`0 0 ${viewSize} ${viewSize}`}
aria-label={t("effectiveness.successRateAria", { rate: clampedRate.toFixed(1) })}
role="img"
>
{/* Track */}
<circle
cx={center}
cy={center}
r={RING_RADIUS}
fill="none"
stroke="#2a2a3d"
strokeWidth={RING_STROKE}
/>
{/* Arc */}
<circle
cx={center}
cy={center}
r={RING_RADIUS}
fill="none"
stroke={color}
strokeWidth={RING_STROKE}
strokeDasharray={`${filled} ${gap}`}
strokeLinecap="round"
transform={`rotate(-90 ${center} ${center})`}
style={{ transition: "stroke-dasharray 0.6s ease" }}
/>
{/* Percentage label */}
<text
x={center}
y={center}
textAnchor="middle"
dominantBaseline="central"
fill="#e4e4ed"
fontSize="13"
fontWeight="600"
fontFamily="Inter, sans-serif"
>
{clampedRate.toFixed(0)}%
</text>
</svg>
<span className="text-[10px] font-medium text-gray-500 uppercase tracking-wider">
{t("effectiveness.success")}
</span>
</div>
);
}
interface SparklineProps {
data: number[];
color: string;
}
interface SparklineTooltipState {
index: number;
/** Bounding rect of the hovered bar (in viewport coordinates). */
rect: DOMRect;
}
function Sparkline({ data, color }: SparklineProps) {
const { t, i18n } = useTranslation(["workflows", "common"]);
const locale = i18n.resolvedLanguage ?? i18n.language;
const dayLabels = useMemo(
() =>
Array.from({ length: 7 }, (_, day) =>
new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
new Date(Date.UTC(2026, 0, 5 + day))
)
),
[locale]
);
const [tip, setTip] = useState<SparklineTooltipState | null>(null);
const bars = data.length > 0 ? data : Array.from({ length: 7 }, () => 0);
const max = Math.max(...bars, 1);
return (
<div aria-label={t("effectiveness.weeklyActivityAria")}>
{/* Bars */}
<div className="flex items-end gap-1 h-8 relative" onMouseLeave={() => setTip(null)}>
{bars.map((value, i) => {
const heightPct = Math.max((value / max) * 100, value > 0 ? 8 : 4);
return (
<div
key={i}
className="flex-1 relative"
style={{ height: "100%" }}
onMouseEnter={(e) =>
setTip({ index: i, rect: e.currentTarget.getBoundingClientRect() })
}
>
{/* Bar (anchored to bottom) */}
<div
className="absolute bottom-0 left-0 right-0 rounded-sm transition-all duration-300"
style={{
height: `${heightPct}%`,
backgroundColor: value > 0 ? color : "#2a2a3d",
opacity: tip?.index === i ? 1 : value > 0 ? 0.85 : 0.4,
}}
/>
</div>
);
})}
</div>
{/* Day labels */}
<div className="flex gap-1 mt-1">
{bars.map((_, i) => (
<span
key={i}
className="flex-1 text-center text-[8px] text-gray-600 leading-none select-none"
>
{dayLabels[i % dayLabels.length] ?? ""}
</span>
))}
</div>
{tip && (
<SparklineTooltip
rect={tip.rect}
label={dayLabels[tip.index % dayLabels.length] ?? ""}
value={bars[tip.index] ?? 0}
color={color}
/>
)}
</div>
);
}
/**
* Tooltip is rendered into `document.body` via a portal so the parent
* ScoreCard's `overflow-hidden` (and hover-transform that would otherwise
* become its containing block) cannot clip it. Coordinates are computed
* from the hovered bar's bounding rect and clamped to the viewport with an
* 8 px margin, so the tooltip can never be cut off on any day of the week.
*/
function SparklineTooltip({
rect,
label,
value,
color,
}: {
rect: DOMRect;
label: string;
value: number;
color: string;
}) {
const { t } = useTranslation("workflows");
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number }>({
left: rect.left,
top: rect.top,
});
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const w = el.offsetWidth;
const h = el.offsetHeight;
const margin = 8;
// Center horizontally over the bar, then clamp to viewport.
let left = rect.left + rect.width / 2 - w / 2;
if (left < margin) left = margin;
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
// Default above the bar; flip below if there isn't room.
let top = rect.top - h - 8;
if (top < margin) top = rect.bottom + 8;
setPos({ left, top });
}, [rect]);
if (typeof document === "undefined") return null;
return createPortal(
<div
ref={ref}
role="tooltip"
className="fixed z-[60] px-2 py-1 bg-[#12121f] border border-[#2a2a4a] rounded-md shadow-xl text-[10px] text-gray-200 whitespace-nowrap pointer-events-none"
style={{ left: pos.left, top: pos.top }}
>
<span className="font-medium">{label}</span>
<span className="text-gray-400 mx-1">·</span>
<span className="tabular-nums" style={{ color }}>
{t("effectiveness.sessionCount", { count: value })}
</span>
</div>,
document.body
);
}
interface MetricBoxProps {
label: string;
value: string;
}
function MetricBox({ label, value }: MetricBoxProps) {
return (
<div className="flex flex-col items-center gap-0.5 bg-surface-3 rounded-lg px-2 py-2 flex-1 min-w-0 overflow-hidden">
<span className="text-xs font-semibold text-gray-200 tabular-nums truncate w-full text-center">
{value}
</span>
<span className="text-[9px] text-gray-500 uppercase tracking-wider truncate w-full text-center">
{label}
</span>
</div>
);
}
interface ScoreCardProps {
item: SubagentEffectivenessItem;
colorIndex: number;
}
function ScoreCard({ item, colorIndex }: ScoreCardProps) {
const { t } = useTranslation("workflows");
const color = COLORS[colorIndex % COLORS.length] ?? COLORS[0];
return (
<div
className="
bg-surface-2 border border-border rounded-xl p-4
flex flex-col gap-4 min-w-0 overflow-hidden
transition-all duration-200
hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/30 hover:border-border-light
"
>
{/* Header */}
<div className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<span className="text-sm font-medium text-gray-200 truncate" title={item.subagent_type}>
{item.subagent_type}
</span>
</div>
{/* Success ring */}
<div className="flex justify-center">
<SuccessRing rate={item.successRate} color={color} />
</div>
{/* Metric boxes */}
<div className="flex gap-2">
<MetricBox label={t("effectiveness.sessions")} value={String(item.sessions)} />
<MetricBox
label={t("effectiveness.avgDuration")}
value={formatDurationSec(item.avgDuration)}
/>
</div>
{/* Sparkline */}
<div className="flex flex-col gap-1">
<span className="text-[10px] text-gray-500 uppercase tracking-wider">
{t("effectiveness.weeklyActivity")}
</span>
<Sparkline data={item.trend} color={color} />
</div>
</div>
);
}
export interface SubagentEffectivenessProps {
data: SubagentEffectivenessItem[];
}
export function SubagentEffectiveness({ data }: SubagentEffectivenessProps) {
const { t } = useTranslation("workflows");
if (data.length === 0) {
return (
<div className="flex items-center justify-center py-16 text-gray-500 text-sm">
{t("effectiveness.noData")}
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{data.map((item, i) => (
<ScoreCard key={item.subagent_type} item={item} colorIndex={i} />
))}
</div>
);
}
@@ -0,0 +1,671 @@
/**
* @file ToolExecutionFlow.tsx
* @description Defines the ToolExecutionFlow component that visualizes the flow of tool usage in agent workflows using a Sankey diagram. It processes the provided tool flow data, constructs a Sankey graph, and renders it using D3.js. The component also includes interactive tooltips for links and a legend for tool types. It handles responsiveness and edge cases such as empty data gracefully.
* @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
* - `ToolExecutionFlow` — 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).
* -----------------------------------------------------------------------------
* **ToolExecutionFlow**
* 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, useState, useCallback } from "react";
import * as d3 from "d3";
import { sankey, sankeyLinkHorizontal } from "d3-sankey";
import type { SankeyGraph, SankeyNode, SankeyLink } from "d3-sankey";
import { useTranslation } from "react-i18next";
import type { ToolFlowData } from "../../lib/types";
// ── Constants ─────────────────────────────────────────────────────────────────
const MARGIN = { top: 24, right: 140, bottom: 24, left: 140 };
const NODE_WIDTH = 14;
const NODE_PADDING = 18;
const MIN_NODE_HEIGHT = 6;
const LINK_OPACITY_DEFAULT = 0.15;
const LINK_OPACITY_HOVER = 0.45;
const TOOL_COLORS: Record<string, string> = {
Read: "#3b82f6",
Write: "#22c55e",
Edit: "#eab308",
Bash: "#ef4444",
Grep: "#a855f7",
Glob: "#ec4899",
Agent: "#6366f1",
};
const COLOR_DEFAULT = "#64748b";
function toolColor(name: string): string {
// Strip the _source / _target suffix we add internally
const base = name.replace(/_(source|target)$/, "");
return TOOL_COLORS[base] ?? COLOR_DEFAULT;
}
function toolLabel(name: string): string {
return name.replace(/_(source|target)$/, "");
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface NodeExtra {
id: string;
}
interface LinkExtra {
uid: string;
}
type SNode = SankeyNode<NodeExtra, LinkExtra>;
type SLink = SankeyLink<NodeExtra, LinkExtra>;
type SGraph = SankeyGraph<NodeExtra, LinkExtra>;
interface NodeTipPayload {
kind: "node";
rawName: string;
count: number;
shareOfTotal: number;
}
interface LinkTipPayload {
kind: "link";
source: string;
target: string;
count: number;
shareOfSource: number;
shareOfTarget: number;
}
type TipPayload = NodeTipPayload | LinkTipPayload;
// ── Props ─────────────────────────────────────────────────────────────────────
interface ToolExecutionFlowProps {
data: ToolFlowData;
filterAgentType?: string | null;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* d3-sankey collapses self-loops and duplicate node references. To represent a
* tool appearing as both source and target we suffix the node id with
* `_source` or `_target` and deduplicate at the label layer.
*
* Strategy:
* - A node that ONLY appears as a source keeps its plain name.
* - A node that ONLY appears as a target keeps its plain name.
* - A node that appears on BOTH sides gets `_source` / `_target` copies.
*/
function buildSankeyInput(data: ToolFlowData): {
nodes: NodeExtra[];
links: Array<{ source: string; target: string; value: number; uid: string }>;
} {
const { transitions } = data;
if (transitions.length === 0) return { nodes: [], links: [] };
const sourcesSet = new Set(transitions.map((t) => t.source));
const targetsSet = new Set(transitions.map((t) => t.target));
// Nodes that appear on both sides need splitting
const bothSides = new Set<string>();
for (const s of sourcesSet) {
if (targetsSet.has(s)) bothSides.add(s);
}
const nodeIdSet = new Set<string>();
function sourceId(name: string): string {
return bothSides.has(name) ? `${name}_source` : name;
}
function targetId(name: string): string {
return bothSides.has(name) ? `${name}_target` : name;
}
const links = transitions.map((t, i) => ({
source: sourceId(t.source),
target: targetId(t.target),
value: Math.max(1, t.value),
uid: `link-${i}`,
}));
for (const l of links) {
nodeIdSet.add(l.source);
nodeIdSet.add(l.target);
}
const nodes: NodeExtra[] = Array.from(nodeIdSet).map((id) => ({ id }));
return { nodes, links };
}
// ── Component ─────────────────────────────────────────────────────────────────
export function ToolExecutionFlow({
data,
filterAgentType: _filterAgentType,
}: ToolExecutionFlowProps) {
const { t } = useTranslation("workflows");
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const tipRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 700, height: 420 });
// Track container width for responsiveness
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const w = Math.floor(entry.contentRect.width);
if (w > 0) {
setDimensions((prev) => ({
...prev,
width: w,
}));
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const isEmpty = data.transitions.length === 0 || data.transitions.every((t) => t.value === 0);
const totalUsage = data.toolCounts.reduce((s, c) => s + c.count, 0);
const hideTip = useCallback(() => {
const tip = tipRef.current;
if (tip) tip.style.opacity = "0";
}, []);
const localizeToolLabel = useCallback(
(name: string) => {
const lower = name.toLowerCase();
switch (lower) {
case "read":
case "write":
case "edit":
case "bash":
case "grep":
case "glob":
case "agent":
case "other":
return t(`errors:toolLegend.${lower}`);
default:
return name;
}
},
[t]
);
const showTip = useCallback(
(payload: TipPayload, anchorEl: SVGGraphicsElement) => {
const tip = tipRef.current;
if (!tip) return;
buildToolFlowTooltip(tip, payload, localizeToolLabel, t);
// Position
const r = anchorEl.getBoundingClientRect();
tip.style.opacity = "0";
tip.style.display = "block";
const tipW = tip.offsetWidth || 280;
const tipH = tip.offsetHeight || 160;
const margin = 8;
let left = r.left + r.width / 2 - tipW / 2;
if (left < margin) left = margin;
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
let top = r.top - tipH - 10;
if (top < margin) top = r.bottom + 10;
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
tip.style.opacity = "1";
},
[localizeToolLabel, t]
);
useEffect(() => {
const svgEl = svgRef.current;
if (!svgEl || isEmpty) return;
const { width, height } = dimensions;
const innerW = width - MARGIN.left - MARGIN.right;
const innerH = height - MARGIN.top - MARGIN.bottom;
if (innerW <= 0 || innerH <= 0) return;
// Clear previous render
d3.select(svgEl).selectAll("*").remove();
const { nodes: rawNodes, links: rawLinks } = buildSankeyInput(data);
if (rawNodes.length === 0) return;
// Build sankey layout
const sankeyGen = sankey<NodeExtra, LinkExtra>()
.nodeId((d) => d.id)
.nodeWidth(NODE_WIDTH)
.nodePadding(NODE_PADDING)
.nodeSort(null) // preserve insertion order
.extent([
[0, 0],
[innerW, innerH],
]);
let graph: SGraph;
try {
graph = sankeyGen({
nodes: rawNodes.map((n) => ({ ...n })),
links: rawLinks.map((l) => ({ ...l })),
});
} catch {
// If layout fails (e.g., cycles), bail gracefully
return;
}
// Enforce minimum node height by adjusting y0/y1
for (const node of graph.nodes) {
const n = node as SNode;
if (n.y0 !== undefined && n.y1 !== undefined) {
const h = n.y1 - n.y0;
if (h < MIN_NODE_HEIGHT) {
const mid = (n.y0 + n.y1) / 2;
n.y0 = mid - MIN_NODE_HEIGHT / 2;
n.y1 = mid + MIN_NODE_HEIGHT / 2;
}
}
}
// Re-run update step so links follow the adjusted node positions
sankeyGen.update(graph);
const svg = d3
.select(svgEl)
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("preserveAspectRatio", "xMidYMid meet");
const root = svg.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
// ── Gradient defs ──────────────────────────────────────────────────────
const defs = svg.append("defs");
(graph.links as SLink[]).forEach((link, i) => {
const sourceNode = link.source as SNode;
const targetNode = link.target as SNode;
const gradId = `link-grad-${i}`;
const grad = defs
.append("linearGradient")
.attr("id", gradId)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", sourceNode.x1 ?? 0)
.attr("x2", targetNode.x0 ?? 0);
const srcColor = toolColor(sourceNode.id);
const tgtColor = toolColor(targetNode.id);
grad.append("stop").attr("offset", "0%").attr("stop-color", srcColor);
grad.append("stop").attr("offset", "100%").attr("stop-color", tgtColor);
(link as SLink & { _gradId: string })._gradId = gradId;
});
// ── Links ──────────────────────────────────────────────────────────────
const linkPath = sankeyLinkHorizontal();
const linkGroup = root.append("g").attr("class", "links");
// Pre-compute outgoing/incoming totals per node so we can show share-of-source
// and share-of-target percentages in the tooltip.
const outgoingByNode = new Map<string, number>();
const incomingByNode = new Map<string, number>();
for (const link of graph.links as SLink[]) {
const src = (link.source as SNode).id;
const tgt = (link.target as SNode).id;
outgoingByNode.set(src, (outgoingByNode.get(src) ?? 0) + (link.value ?? 0));
incomingByNode.set(tgt, (incomingByNode.get(tgt) ?? 0) + (link.value ?? 0));
}
linkGroup
.selectAll<SVGPathElement, SLink>("path")
.data(graph.links as SLink[])
.join("path")
.attr("d", (d) => linkPath(d) ?? "")
.attr("stroke", (d, i) => {
const gradId = (graph.links[i] as SLink & { _gradId?: string })._gradId;
return gradId ? `url(#${gradId})` : toolColor((d.source as SNode).id);
})
.attr("stroke-width", (d) => Math.max(1, d.width ?? 1))
.attr("fill", "none")
.attr("stroke-opacity", LINK_OPACITY_DEFAULT)
.style("cursor", "default")
.on("mouseenter", function (_event: MouseEvent, d: SLink) {
d3.select(this).attr("stroke-opacity", LINK_OPACITY_HOVER);
const src = (d.source as SNode).id;
const tgt = (d.target as SNode).id;
const srcOut = outgoingByNode.get(src) ?? 0;
const tgtIn = incomingByNode.get(tgt) ?? 0;
showTip(
{
kind: "link",
source: toolLabel(src),
target: toolLabel(tgt),
count: d.value ?? 0,
shareOfSource: srcOut > 0 ? (d.value ?? 0) / srcOut : 0,
shareOfTarget: tgtIn > 0 ? (d.value ?? 0) / tgtIn : 0,
},
this as SVGGraphicsElement
);
})
.on("mouseleave", function () {
d3.select(this).attr("stroke-opacity", LINK_OPACITY_DEFAULT);
hideTip();
});
// ── Nodes ──────────────────────────────────────────────────────────────
const nodeGroup = root.append("g").attr("class", "nodes");
const nodeGs = nodeGroup
.selectAll<SVGGElement, SNode>("g")
.data(graph.nodes as SNode[])
.join("g");
nodeGs
.append("rect")
.attr("x", (d) => d.x0 ?? 0)
.attr("y", (d) => d.y0 ?? 0)
.attr("width", (d) => (d.x1 ?? 0) - (d.x0 ?? 0))
.attr("height", (d) => Math.max(MIN_NODE_HEIGHT, (d.y1 ?? 0) - (d.y0 ?? 0)))
.attr("rx", 2)
.attr("ry", 2)
.attr("fill", (d) => toolColor(d.id))
.attr("stroke-width", 0)
.attr("fill-opacity", 0.9)
.style("cursor", "default")
.on("mouseenter", function (_event: MouseEvent, d: SNode) {
const rawName = toolLabel(d.id);
const countEntry = data.toolCounts.find((c) => c.tool_name === rawName);
const count = countEntry?.count ?? 0;
showTip(
{
kind: "node",
rawName,
count,
shareOfTotal: totalUsage > 0 ? count / totalUsage : 0,
},
this as SVGGraphicsElement
);
})
.on("mouseleave", function () {
hideTip();
});
// ── Node labels ────────────────────────────────────────────────────────
nodeGs.each(function (d: SNode) {
const g = d3.select(this);
const nodeX0 = d.x0 ?? 0;
const nodeX1 = d.x1 ?? 0;
const nodeY0 = d.y0 ?? 0;
const nodeY1 = d.y1 ?? 0;
const nodeH = nodeY1 - nodeY0;
const midY = nodeY0 + nodeH / 2;
const rawLabel = toolLabel(d.id);
const label = localizeToolLabel(rawLabel);
const isRightSide = (nodeX0 + nodeX1) / 2 > innerW / 2;
// Percentage of total
const countEntry = data.toolCounts.find((c) => c.tool_name === rawLabel);
const pct =
countEntry && totalUsage > 0
? ` ${((countEntry.count / totalUsage) * 100).toFixed(1)}%`
: "";
const textX = isRightSide ? nodeX1 + 8 : nodeX0 - 8;
const anchor = isRightSide ? "start" : "end";
const text = g
.append("text")
.attr("x", textX)
.attr("y", midY)
.attr("dy", "0.35em")
.attr("text-anchor", anchor)
.style("font-size", "12px")
.style("font-family", "Inter, -apple-system, sans-serif")
.style("fill", "#e2e8f0")
.style("pointer-events", "none")
.style("user-select", "none");
text.append("tspan").text(label).style("font-weight", "500");
if (pct) {
text.append("tspan").text(pct).style("fill", "#64748b").style("font-size", "11px");
}
});
// Hide any stale tooltip when the chart re-renders so it cannot get stuck.
hideTip();
}, [data, dimensions, isEmpty, localizeToolLabel, t, totalUsage, showTip, hideTip]);
// Adapt SVG height based on node count so tall graphs don't crush
useEffect(() => {
const nodeCount = new Set(data.transitions.flatMap((t) => [t.source, t.target])).size;
const estimatedH = Math.max(
320,
Math.min(600, nodeCount * (NODE_PADDING + 20) + MARGIN.top + MARGIN.bottom)
);
setDimensions((prev) => ({ ...prev, height: estimatedH }));
}, [data.transitions]);
return (
<div className="relative" ref={containerRef} onMouseLeave={hideTip}>
{isEmpty ? (
<div className="flex items-center justify-center" style={{ height: dimensions.height }}>
<span className="text-sm text-gray-500">{t("toolFlow.noData")}</span>
</div>
) : (
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
preserveAspectRatio="xMidYMid meet"
style={{ display: "block", width: "100%", height: dimensions.height }}
onMouseLeave={hideTip}
/>
)}
<Legend />
<div
ref={tipRef}
role="tooltip"
aria-hidden="true"
className="fixed z-50 px-3 py-2 rounded-lg shadow-2xl pointer-events-none"
style={{
display: "none",
opacity: 0,
left: 0,
top: 0,
background: "#12121f",
border: "1px solid #2a2a4a",
color: "#e2e8f0",
minWidth: 240,
maxWidth: 320,
transition: "opacity 120ms ease-out",
}}
/>
</div>
);
}
// ── Tooltip DOM builder ───────────────────────────────────────────────────────
function fmtPct(v: number): string {
if (v <= 0) return "-";
if (v < 0.01) return "<1%";
return `${(v * 100).toFixed(1)}%`;
}
function appendTipRow(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;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);
parent.appendChild(row);
}
type TFn = (key: string, options?: Record<string, unknown>) => string;
function buildToolFlowTooltip(
el: HTMLDivElement,
payload: TipPayload,
localizeToolLabel: (name: string) => string,
t: TFn
) {
while (el.firstChild) el.removeChild(el.firstChild);
if (payload.kind === "node") {
const name = localizeToolLabel(payload.rawName);
const title = document.createElement("p");
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0";
title.textContent = name;
el.appendChild(title);
const subtitle = document.createElement("p");
subtitle.style.cssText =
"font-size:10px;color:#64748b;margin:2px 0 8px;text-transform:uppercase;letter-spacing:0.05em";
subtitle.textContent = t("toolFlow.tooltip.node");
el.appendChild(subtitle);
appendTipRow(el, t("toolFlow.tooltip.totalCalls"), payload.count.toLocaleString());
appendTipRow(el, t("toolFlow.tooltip.shareOfAll"), fmtPct(payload.shareOfTotal));
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:8px 0 0";
desc.textContent = t("toolFlow.tooltip.nodeDescFmt", { name });
el.appendChild(desc);
return;
}
// Link tooltip
const src = localizeToolLabel(payload.source);
const tgt = localizeToolLabel(payload.target);
const title = document.createElement("p");
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0";
const tspanArrow = document.createElement("span");
tspanArrow.style.color = "#64748b";
tspanArrow.textContent = " → ";
title.appendChild(document.createTextNode(src));
title.appendChild(tspanArrow);
title.appendChild(document.createTextNode(tgt));
el.appendChild(title);
const subtitle = document.createElement("p");
subtitle.style.cssText =
"font-size:10px;color:#64748b;margin:2px 0 8px;text-transform:uppercase;letter-spacing:0.05em";
subtitle.textContent = t("toolFlow.tooltip.link");
el.appendChild(subtitle);
appendTipRow(el, t("toolFlow.tooltip.transitionsObserved"), payload.count.toLocaleString());
appendTipRow(
el,
t("toolFlow.tooltip.shareOfSourceFmt", { source: src }),
fmtPct(payload.shareOfSource)
);
appendTipRow(
el,
t("toolFlow.tooltip.shareOfTargetFmt", { target: tgt }),
fmtPct(payload.shareOfTarget)
);
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:8px 0 0";
desc.textContent = t("toolFlow.tooltip.linkDescFmt", { source: src, target: tgt });
el.appendChild(desc);
}
// ── Legend ────────────────────────────────────────────────────────────────────
const LEGEND_ITEMS: Array<{ key: string; color: string }> = [
{ key: "read", color: "#3b82f6" },
{ key: "write", color: "#22c55e" },
{ key: "edit", color: "#eab308" },
{ key: "bash", color: "#ef4444" },
{ key: "grep", color: "#a855f7" },
{ key: "glob", color: "#ec4899" },
{ key: "agent", color: "#6366f1" },
{ key: "other", color: "#64748b" },
];
function Legend() {
const { t } = useTranslation("errors");
return (
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-3 px-1">
{LEGEND_ITEMS.map(({ key, color }) => (
<div key={key} className="flex items-center gap-1.5">
<span
style={{ background: color, opacity: 0.9 }}
className="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0"
/>
<span className="text-xs text-gray-400">{t(`toolLegend.${key}`)}</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,400 @@
/**
* @file WorkflowPatterns.tsx
* @description Defines the WorkflowPatterns React component that visualizes common workflow patterns detected from session data. It displays a ranked list of patterns based on their frequency, showing the sequence of agent steps in each pattern along with an icon representing the type of workflow. The component also handles cases where no patterns are detected and includes a special item for solo sessions without subagents. Users can click on a pattern to trigger a callback with the pattern's steps for further analysis or filtering.
* @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
* - `WorkflowPatterns` — 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).
* -----------------------------------------------------------------------------
* **WorkflowPatterns**
* 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 { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, Zap, Code2, Shield, Bug, FileText, Lightbulb, Info } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { WorkflowPattern, WorkflowPatternsData } from "../../lib/types";
type TFn = (key: string, options?: Record<string, unknown>) => string;
// ── Constants ─────────────────────────────────────────────────────────────────
const MAX_VISIBLE_STEPS = 4;
// ── Helpers ───────────────────────────────────────────────────────────────────
function patternIcon(steps: string[]): LucideIcon {
const joined = steps.join(" ").toLowerCase();
if (joined.includes("debug")) return Bug;
if (joined.includes("security") || joined.includes("audit")) return Shield;
if (joined.includes("code-review") || joined.includes("review")) return Code2;
if (joined.includes("doc") || joined.includes("text")) return FileText;
return Zap;
}
/**
* Find the first agent that appears more than once in the sequence (loop indicator).
* Returns null if every step is unique.
*/
function findRepeatedStep(steps: string[]): string | null {
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
if (s !== undefined && steps.indexOf(s) !== i) return s;
}
return null;
}
/**
* Build a deterministic, value-dependent narrative for a workflow pattern.
* Pure rule-based mapping - same input always yields the same output, so the
* UI never produces hallucinated descriptions for ambiguous patterns.
*/
function describePattern(pattern: WorkflowPattern, t: TFn): string {
const { steps, percentage } = pattern;
if (steps.length === 0) return "";
const first = steps[0] ?? "";
const last = steps[steps.length - 1] ?? first;
const repeated = findRepeatedStep(steps);
// Shape of the chain
let core: string;
if (steps.length === 1) {
core = t("patterns.detail.narrative.soloFmt", { first });
} else if (steps.length === 2) {
core = t("patterns.detail.narrative.twoStepFmt", { first, last });
} else if (steps.length <= 5) {
core = t("patterns.detail.narrative.shortFmt", { count: steps.length, first, last });
} else {
core = t("patterns.detail.narrative.longFmt", { count: steps.length, first, last });
}
if (repeated) {
core += t("patterns.detail.narrative.loopHintFmt", { agent: repeated });
}
// Frequency bucket
let freq: string;
if (percentage > 50) freq = t("patterns.detail.narrative.dominant");
else if (percentage > 25) freq = t("patterns.detail.narrative.common");
else if (percentage > 10) freq = t("patterns.detail.narrative.regular");
else freq = t("patterns.detail.narrative.niche");
return core + freq;
}
/**
* Pick a suggestion bucket based on chain length and whether a loop exists.
* Loop wins over length so the user is reminded to confirm intentional loops.
*/
function suggestionForPattern(pattern: WorkflowPattern, t: TFn): string {
const { steps } = pattern;
if (findRepeatedStep(steps)) return t("patterns.detail.suggestion.loop");
if (steps.length <= 1) return t("patterns.detail.suggestion.solo");
if (steps.length <= 3) return t("patterns.detail.suggestion.shortChain");
if (steps.length <= 6) return t("patterns.detail.suggestion.mediumChain");
return t("patterns.detail.suggestion.longChain");
}
// ── Sub-components ────────────────────────────────────────────────────────────
function StepPill({ label }: { label: string }) {
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
{label}
</span>
);
}
function StepFlow({ steps }: { steps: string[] }) {
const { t } = useTranslation("workflows");
const visible = steps.slice(0, MAX_VISIBLE_STEPS);
const overflow = steps.length - MAX_VISIBLE_STEPS;
return (
<div className="flex items-center flex-wrap gap-1 min-w-0">
{visible.map((step, idx) => (
<span key={idx} className="flex items-center gap-1">
<StepPill label={step} />
{(idx < visible.length - 1 || overflow > 0) && (
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-gray-600" />
)}
</span>
))}
{overflow > 0 && (
<span className="inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium bg-gray-700/50 text-gray-400 border border-gray-600/20 whitespace-nowrap">
{t("common:plusMore", { count: overflow })}
</span>
)}
</div>
);
}
function PatternFrequency({ count, percentage }: { count: number; percentage: number }) {
const { t } = useTranslation("workflows");
return (
<div className="flex-shrink-0 text-right">
<p className="text-sm font-semibold text-gray-100">{count.toLocaleString()}</p>
<p className="text-xs text-gray-500">
{percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })}
</p>
</div>
);
}
interface PatternItemProps {
pattern: WorkflowPattern;
rank: number;
isSelected: boolean;
onClick: () => void;
}
function PatternItem({ pattern, rank, isSelected, onClick }: PatternItemProps) {
const { t } = useTranslation("workflows");
const Icon = patternIcon(pattern.steps);
return (
<div
className={[
"rounded-lg border transition-colors duration-150 overflow-hidden",
isSelected
? "bg-indigo-500/10 border-indigo-500/30"
: "bg-surface-2 border-transparent hover:bg-white/5 hover:border-white/10",
].join(" ")}
>
<button
type="button"
onClick={onClick}
aria-expanded={isSelected}
title={t("patterns.detail.clickHint")}
className="w-full flex items-center gap-3 px-4 py-3 text-left"
>
{/* Rank / icon */}
<div className="flex-shrink-0 w-7 h-7 rounded-md bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center">
{rank <= 3 ? (
<span className="text-xs font-bold text-indigo-400">{rank}</span>
) : (
<Icon className="w-3.5 h-3.5 text-indigo-400" />
)}
</div>
{/* Step flow */}
<div className="flex-1 min-w-0 overflow-hidden">
<StepFlow steps={pattern.steps} />
</div>
{/* Frequency */}
<PatternFrequency count={pattern.count} percentage={pattern.percentage} />
{/* Click affordance - visible only when not yet expanded so users know the row is interactive. */}
{!isSelected && (
<Info className="hidden sm:block w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
)}
</button>
{isSelected && <PatternDetail pattern={pattern} />}
</div>
);
}
function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
const { t } = useTranslation("workflows");
const uniqueAgents = new Set(pattern.steps).size;
const narrative = describePattern(pattern, t);
const suggestion = suggestionForPattern(pattern, t);
return (
<div className="border-t border-indigo-500/20 bg-surface-1/40 px-4 py-3.5 space-y-3.5">
{/* Full step sequence (no truncation) */}
<div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">
{t("patterns.detail.stepsHeading")}
</p>
<div className="flex items-center flex-wrap gap-1.5">
{pattern.steps.map((step, i) => (
<span key={i} className="flex items-center gap-1.5">
<span className="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-indigo-500/15 text-indigo-200 border border-indigo-500/25">
<span className="text-indigo-400/70 mr-1.5 text-[10px] font-bold">{i + 1}</span>
{step}
</span>
{i < pattern.steps.length - 1 && (
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-gray-600" />
)}
</span>
))}
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<DetailStat
label={t("patterns.detail.stepsCount", { count: pattern.steps.length })}
value={String(pattern.steps.length)}
/>
<DetailStat label={t("patterns.detail.uniqueAgents")} value={String(uniqueAgents)} />
<DetailStat
label={t("patterns.detail.occurrences")}
value={pattern.count.toLocaleString()}
/>
<DetailStat
label={t("patterns.detail.shareOfSessions")}
value={`${pattern.percentage.toFixed(1)}%`}
/>
</div>
{/* Narrative - what this means */}
<div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Info className="w-3 h-3 text-indigo-400" />
{t("patterns.detail.narrativeHeading")}
</p>
<p className="text-xs text-gray-300 leading-relaxed">{narrative}</p>
</div>
{/* Suggestion */}
<div className="bg-indigo-500/5 border border-indigo-500/15 rounded-md px-3 py-2.5">
<p className="text-[10px] font-semibold text-indigo-300 uppercase tracking-wider mb-1 flex items-center gap-1.5">
<Lightbulb className="w-3 h-3" />
{t("patterns.detail.suggestionHeading")}
</p>
<p className="text-xs text-gray-300 leading-relaxed">{suggestion}</p>
</div>
</div>
);
}
function DetailStat({ label, value }: { label: string; value: string }) {
return (
<div className="bg-surface-2 border border-border rounded-md px-2.5 py-2">
<p className="text-sm font-semibold text-gray-100 tabular-nums">{value}</p>
<p className="text-[10px] text-gray-500 uppercase tracking-wider mt-0.5 truncate">{label}</p>
</div>
);
}
function SoloSessionItem({ count, percentage }: { count: number; percentage: number }) {
const { t } = useTranslation("workflows");
return (
<div className="flex items-center gap-3 px-4 py-3 rounded-lg border bg-yellow-500/5 border-yellow-500/20">
<div className="flex-shrink-0 w-7 h-7 rounded-md bg-yellow-500/10 border border-yellow-500/20 flex items-center justify-center">
<Zap className="w-3.5 h-3.5 text-yellow-400" />
</div>
<div className="flex-1 min-w-0">
<span className="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-yellow-500/15 text-yellow-300 border border-yellow-500/20">
{t("patterns.solo")}
</span>
</div>
<div className="flex-shrink-0 text-right">
<p className="text-sm font-semibold text-gray-100">{count.toLocaleString()}</p>
<p className="text-xs text-gray-500">
{percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })}
</p>
</div>
</div>
);
}
function EmptyPatterns() {
const { t } = useTranslation("workflows");
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
<Zap className="w-5 h-5 text-gray-600" />
</div>
<p className="text-sm font-medium text-gray-400">{t("patterns.noData")}</p>
<p className="text-xs text-gray-600 mt-1">{t("patterns.noDataDesc")}</p>
</div>
);
}
// ── Public component ──────────────────────────────────────────────────────────
interface WorkflowPatternsProps {
data: WorkflowPatternsData;
onPatternClick?: (steps: string[]) => void;
}
export function WorkflowPatterns({ data, onPatternClick }: WorkflowPatternsProps) {
const { t } = useTranslation("workflows");
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const handlePatternClick = (index: number, steps: string[]) => {
const next = selectedIndex === index ? null : index;
setSelectedIndex(next);
if (next !== null) {
onPatternClick?.(steps);
}
};
const hasContent = data.patterns.length > 0 || data.soloSessionCount > 0;
return (
<div className="card p-5">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4">
{t("patterns.label")}
</h2>
{!hasContent ? (
<EmptyPatterns />
) : (
<div className="flex flex-col gap-2">
{data.patterns.map((pattern, idx) => (
<PatternItem
key={idx}
pattern={pattern}
rank={idx + 1}
isSelected={selectedIndex === idx}
onClick={() => handlePatternClick(idx, pattern.steps)}
/>
))}
{data.soloSessionCount > 0 && (
<SoloSessionItem count={data.soloSessionCount} percentage={data.soloPercentage} />
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,612 @@
/**
* @file WorkflowRunsPanel.tsx
* @description Surfaces dynamic Workflow-tool runs (issue #167) - fleets of
* inner sub-agents spawned by the Claude Code "Workflow" tool, ingested from
* on-disk run journals. Works in two modes: controlled (pass `runs`, e.g. from
* SessionDetail) or self-fetching (pass a `statusFilter`, e.g. the Workflows
* page) with live `workflow_upserted` updates. Each run expands to colored,
* clickable phase filters, a per-agent metrics table, and an expandable list of
* per-agent results. The collapsed row shows a short teaser from the run
* journal; expanding an agent lazily fetches its full transcript (the journal
* only carries server-truncated previews) and renders the complete prompt and
* result, falling back to the teaser when the transcript is pruned/unavailable.
* @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/api`
* - `../../lib/eventBus`
* - `../../lib/types`
* - `../../lib/format`
*
* ## Public surface
* - `friendlyPreview` — exported API; see TSDoc on the symbol for behavior.
* - `fullPreview` — exported API; see TSDoc on the symbol for behavior.
* - `extractPromptResult` — exported API; see TSDoc on the symbol for behavior.
* - `WorkflowRunsPanel` — 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).
* -----------------------------------------------------------------------------
* **friendlyPreview**
* 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.
*
* **fullPreview**
* 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.
*
* **extractPromptResult**
* 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.
*
* **WorkflowRunsPanel**
* 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 { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Workflow, ChevronRight, ChevronDown, Layers, ExternalLink, Loader2 } from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import type {
WorkflowRun,
WorkflowProgressEntry,
WSMessage,
TranscriptMessage,
} from "../../lib/types";
import { fmt, formatMs, timeAgo, truncate } from "../../lib/format";
type StatusFilter = "all" | "active" | "completed";
interface Props {
/** Controlled mode: render exactly these runs (no fetch, no live updates). */
runs?: WorkflowRun[];
/** Self-fetch mode: page-level status filter (active → running). */
statusFilter?: StatusFilter;
/** Self-fetch mode: scope to one session. */
sessionId?: string;
/** Hide the parent-session link (e.g. when already on that session). */
hideSessionLink?: boolean;
}
const STATUS_STYLES: Record<string, string> = {
running: "bg-amber-500/15 text-amber-400 border-amber-500/30",
working: "bg-amber-500/15 text-amber-400 border-amber-500/30",
queued: "bg-gray-500/15 text-gray-400 border-gray-500/30",
completed: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
done: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
success: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
error: "bg-red-500/15 text-red-400 border-red-500/30",
failed: "bg-red-500/15 text-red-400 border-red-500/30",
};
function statusClass(status: string): string {
return STATUS_STYLES[status] || "bg-gray-500/15 text-gray-400 border-gray-500/30";
}
// Distinct per-phase chip colors, cycled by phase index so every phase
// (e.g. Scout / Verify / Synthesize, or Explain / Interview / Gotcha) reads
// as its own color in both the filter row and the result label chips.
const PHASE_PALETTE = [
"bg-violet-500/15 text-violet-300 border-violet-500/40",
"bg-sky-500/15 text-sky-300 border-sky-500/40",
"bg-amber-500/15 text-amber-300 border-amber-500/40",
"bg-emerald-500/15 text-emerald-300 border-emerald-500/40",
"bg-rose-500/15 text-rose-300 border-rose-500/40",
"bg-cyan-500/15 text-cyan-300 border-cyan-500/40",
"bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40",
];
function phaseColor(phaseTitles: string[], title: string | null | undefined): string {
if (!title) return "bg-gray-500/15 text-gray-300 border-gray-500/40";
const i = phaseTitles.indexOf(title);
const idx = i >= 0 ? i : Math.abs(hashStr(title)) % PHASE_PALETTE.length;
return PHASE_PALETTE[idx % PHASE_PALETTE.length] as string;
}
function hashStr(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return h;
}
/**
* Surface a human-readable excerpt from an agent's result preview, which is
* often a (frequently truncated) JSON blob. Prefer a known content field, then
* the first substantial quoted string, then a de-JSON'd snippet - so the panel
* shows a sentence instead of raw `{"angle":"…","findings":[{"claim":"…`.
*/
export function friendlyPreview(raw: unknown): string {
if (!raw) return "";
const s = String(raw).trim();
const keyed = s.match(
/"(?:claim|pitch|note|text|summary|result|answer|brief|description|title|content)"\s*:\s*"([^"\\]{8,})/i
);
if (keyed && keyed[1]) return keyed[1].trim();
const firstLong = s.match(/"([^"\\]{24,})"/);
if (firstLong && firstLong[1]) return firstLong[1].trim();
if (/^[[{]/.test(s)) {
return s
.replace(/[{}[\]"]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
return s;
}
/** Full, un-truncated content for the expanded view - pretty-printed if JSON. */
export function fullPreview(raw: unknown): string {
if (raw == null) return "";
const s = String(raw);
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
/** Join the text blocks of one transcript message into a single string. */
function messageText(m: TranscriptMessage): string {
return (m.content || [])
.filter((b) => b.type === "text" && b.text)
.map((b) => b.text as string)
.join("\n\n")
.trim();
}
/**
* Derive an agent's full prompt and result from its fetched transcript: the
* first user message carries the task prompt; the last assistant message that
* has text carries the returned result. Either is "" when absent (e.g. a
* schema-mode agent whose final turn is a tool call rather than text) - callers
* fall back to the journal teaser in that case.
*/
export function extractPromptResult(messages: TranscriptMessage[]): {
prompt: string;
result: string;
} {
let prompt = "";
let result = "";
for (const m of messages || []) {
if (m.type === "user" && !prompt) {
const t = messageText(m);
if (t) prompt = t;
} else if (m.type === "assistant") {
const t = messageText(m);
if (t) result = t; // keep the last non-empty assistant text
}
}
return { prompt, result };
}
/** Per-agent transcript fetch state, keyed `${run_id}::${agentId}`. */
interface AgentTranscriptState {
loading: boolean;
prompt?: string;
result?: string;
error?: boolean;
}
export function WorkflowRunsPanel({
runs: controlledRuns,
statusFilter,
sessionId,
hideSessionLink,
}: Props) {
const { t } = useTranslation("workflows");
const controlled = controlledRuns != null;
const [fetchedRuns, setFetchedRuns] = useState<WorkflowRun[]>([]);
const [loading, setLoading] = useState(!controlled);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [phaseFilter, setPhaseFilter] = useState<Record<string, string | null>>({});
const [openResults, setOpenResults] = useState<Set<string>>(() => new Set());
// Full agent transcripts fetched on demand when a result row is expanded,
// keyed `${run_id}::${agentId}`. The run journal only carries truncated
// previews; the complete text lives in the per-agent transcript file.
const [transcripts, setTranscripts] = useState<Record<string, AgentTranscriptState>>({});
const inflightRef = useRef<Set<string>>(new Set());
const loadTranscript = useCallback(
async (sessionId: string, runId: string, agentId: string, key: string) => {
if (inflightRef.current.has(key)) return;
inflightRef.current.add(key);
setTranscripts((prev) => ({ ...prev, [key]: { loading: true } }));
try {
const res = await api.sessions.transcript(sessionId, {
agent_id: agentId,
run_id: runId,
limit: 200,
});
const { prompt, result } = extractPromptResult(res.messages || []);
setTranscripts((prev) => ({ ...prev, [key]: { loading: false, prompt, result } }));
} catch {
setTranscripts((prev) => ({ ...prev, [key]: { loading: false, error: true } }));
}
},
[]
);
const fetchRuns = useCallback(async () => {
if (controlled) return;
try {
const status =
statusFilter === "active"
? "running"
: statusFilter === "completed"
? "completed"
: undefined;
const res = await api.workflows.runs({ status, session_id: sessionId, limit: 200 });
setFetchedRuns(res.runs);
} catch {
/* leave previous runs in place */
} finally {
setLoading(false);
}
}, [controlled, statusFilter, sessionId]);
useEffect(() => {
if (controlled) return;
fetchRuns();
}, [controlled, fetchRuns]);
// Live updates: debounce a refetch when a workflow row changes.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (controlled) return;
const handler = (msg: WSMessage) => {
if (msg.type !== "workflow_upserted") return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(fetchRuns, 1500);
};
const unsub = eventBus.subscribe(handler);
return () => {
unsub();
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [controlled, fetchRuns]);
const runs = controlled ? controlledRuns : fetchedRuns;
const toggle = (runId: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(runId)) next.delete(runId);
else next.add(runId);
return next;
});
const setPhase = (runId: string, phase: string) =>
setPhaseFilter((prev) => ({ ...prev, [runId]: prev[runId] === phase ? null : phase }));
const toggleResult = (key: string) =>
setOpenResults((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
if (!controlled && loading) {
return (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin text-violet-400" />
<span className="animate-pulse">{t("runs.loading")}</span>
</div>
);
}
if (runs.length === 0) {
return (
<div className="text-sm text-gray-500 flex items-center gap-2">
<Workflow className="w-4 h-4 text-gray-600" />
{t("runs.empty")}
</div>
);
}
return (
<div className="space-y-2">
{runs.map((run) => {
const isOpen = expanded.has(run.run_id);
const running = run.status === "running" || run.status === "working";
// progress[] mixes phase markers and agents; only `workflow_agent`
// entries are real agents.
const agentRows = (run.progress || []).filter((p) => p.type === "workflow_agent");
const phaseTitles = (run.phases || []).map((p) => p.title || "").filter(Boolean);
const sel = phaseFilter[run.run_id] || null;
const shown = sel ? agentRows.filter((a) => a.phaseTitle === sel) : agentRows;
const resultRows = shown.filter((a) => a.resultPreview);
return (
<div
key={run.run_id}
className="rounded-lg border border-gray-800 bg-card/40 overflow-hidden"
>
<button
onClick={() => toggle(run.run_id)}
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-gray-800/30 transition-colors"
aria-expanded={isOpen}
>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-gray-500 flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500 flex-shrink-0" />
)}
{running ? (
<Loader2 className="w-4 h-4 text-amber-400 flex-shrink-0 animate-spin" />
) : (
<Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-200 truncate">
{run.name || run.run_id}
</span>
<span className={`badge text-[10px] border ${statusClass(run.status)}`}>
{t(`runs.status.${run.status}`, run.status)}
</span>
{run.default_model && (
<span className="text-[10px] font-mono text-gray-500">{run.default_model}</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5 text-[11px] text-gray-500 flex-wrap">
<span>{t("runs.agents", { count: run.agent_count })}</span>
<span>{t("runs.tools", { count: run.total_tool_calls })}</span>
<span>
{fmt(run.total_tokens)} {t("runs.tokens")}
</span>
{run.duration_ms != null && <span>{formatMs(run.duration_ms)}</span>}
{run.started_at && <span>{timeAgo(run.started_at)}</span>}
</div>
</div>
{!hideSessionLink && (
<Link
to={`/sessions/${encodeURIComponent(run.session_id)}`}
onClick={(e) => e.stopPropagation()}
className="text-gray-500 hover:text-violet-400 transition-colors flex-shrink-0"
title={t("runs.openSession")}
>
<ExternalLink className="w-3.5 h-3.5" />
</Link>
)}
</button>
{isOpen && (
<div className="px-3 pb-3 pt-3 border-t border-gray-800/60 space-y-3">
{/* Clickable, colored phase filters */}
{phaseTitles.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<Layers className="w-3.5 h-3.5 text-gray-500" />
{phaseTitles.map((title, i) => {
const active = sel === title;
return (
<button
key={i}
onClick={() => setPhase(run.run_id, title)}
className={`badge text-[10px] border transition ${phaseColor(phaseTitles, title)} ${
active
? "ring-1 ring-white/50"
: sel
? "opacity-40 hover:opacity-100"
: "hover:ring-1 hover:ring-white/20"
}`}
title={t("runs.filterPhase")}
>
{title}
</button>
);
})}
{sel && (
<button
onClick={() => setPhase(run.run_id, sel)}
className="text-[10px] text-gray-500 hover:text-gray-300 underline"
>
{t("runs.clearFilter")}
</button>
)}
</div>
)}
{shown.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead>
<tr className="text-gray-500 text-left border-b border-gray-800">
<th className="py-1 pr-3 font-medium">{t("runs.col.agent")}</th>
<th className="py-1 pr-3 font-medium">{t("runs.col.phase")}</th>
<th className="py-1 pr-3 font-medium">{t("runs.col.state")}</th>
<th className="py-1 pr-3 font-medium text-right">
{t("runs.col.tokens")}
</th>
<th className="py-1 pr-3 font-medium text-right">
{t("runs.col.tools")}
</th>
<th className="py-1 pr-3 font-medium text-right">
{t("runs.col.duration")}
</th>
</tr>
</thead>
<tbody>
{shown.map((a: WorkflowProgressEntry, i) => (
<tr key={a.agentId || i} className="border-b border-gray-800/40">
<td className="py-1 pr-3 text-gray-300">
{a.label || a.agentType || a.agentId}
{a.lastToolName && (
<span className="text-gray-600 font-mono ml-1">
· {a.lastToolName}
</span>
)}
</td>
<td className="py-1 pr-3">
<span
className={`badge text-[10px] border ${phaseColor(phaseTitles, a.phaseTitle)}`}
>
{a.phaseTitle || "-"}
</span>
</td>
<td className="py-1 pr-3">
<span
className={`badge text-[10px] border ${statusClass(String(a.state || ""))}`}
>
{t(`runs.status.${a.state}`, String(a.state || "-"))}
</span>
</td>
<td className="py-1 pr-3 text-right text-gray-400">
{fmt(a.tokens || 0)}
</td>
<td className="py-1 pr-3 text-right text-gray-400">
{a.toolCalls || 0}
</td>
<td className="py-1 pr-3 text-right text-gray-400">
{a.durationMs != null ? formatMs(a.durationMs) : "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-[11px] text-gray-600">{t("runs.noAgents")}</p>
)}
{/* Clickable, colored, expandable results - full content on click */}
{resultRows.length > 0 && (
<div className="space-y-1.5">
<div className="text-[10px] font-medium uppercase tracking-wider text-gray-600">
{t("runs.resultsLabel")}
<span className="ml-1 text-gray-700">· {resultRows.length}</span>
</div>
{resultRows.map((a, i) => {
const key = `${run.run_id}::${a.agentId || i}`;
const open = openResults.has(key);
const ts = transcripts[key];
const hasFull = !!ts && !ts.loading && !ts.error;
const fullPrompt =
hasFull && ts.prompt
? ts.prompt
: a.promptPreview
? String(a.promptPreview)
: "";
const fullResult =
hasFull && ts.result ? ts.result : fullPreview(a.resultPreview);
return (
<div
key={key}
className="rounded border border-gray-800/70 bg-gray-900/30 overflow-hidden"
>
<button
onClick={() => {
if (!open && a.agentId) {
loadTranscript(run.session_id, run.run_id, a.agentId, key);
}
toggleResult(key);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 text-left hover:bg-gray-800/40 transition-colors"
aria-expanded={open}
>
{open ? (
<ChevronDown className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
)}
<span
className={`badge text-[10px] border flex-shrink-0 ${phaseColor(phaseTitles, a.phaseTitle)}`}
>
{a.label || a.agentType || a.agentId}
</span>
{!open && (
<span className="text-[11px] text-gray-500 leading-snug min-w-0">
{truncate(friendlyPreview(a.resultPreview), 160)}
</span>
)}
</button>
{open && (
<div className="px-2.5 pb-2.5 pt-0.5 space-y-2">
<div className="flex flex-wrap items-center gap-2 text-[10px] text-gray-500">
{a.model && <span className="font-mono">{a.model}</span>}
<span
className={`badge border ${statusClass(String(a.state || ""))}`}
>
{t(`runs.status.${a.state}`, String(a.state || "-"))}
</span>
<span>
{fmt(a.tokens || 0)} {t("runs.tokens")}
</span>
<span>{t("runs.tools", { count: a.toolCalls || 0 })}</span>
{a.durationMs != null && <span>{formatMs(a.durationMs)}</span>}
{ts?.loading && (
<span className="flex items-center gap-1 text-violet-400">
<Loader2 className="w-3 h-3 animate-spin" />
{t("runs.loadingFull")}
</span>
)}
</div>
{fullPrompt && (
<div>
<div className="text-[10px] uppercase tracking-wider text-gray-600 mb-0.5">
{t("runs.promptLabel")}
</div>
<pre className="text-[11px] text-gray-400 whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-48 overflow-auto">
{fullPrompt}
</pre>
</div>
)}
<div>
<div className="text-[10px] uppercase tracking-wider text-gray-600 mb-0.5">
{t("runs.resultLabel")}
</div>
<pre className="text-[11px] text-gray-300 whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-96 overflow-auto">
{fullResult}
</pre>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,386 @@
/**
* @file WorkflowStats.tsx
* @description Six headline statistics rendered as cards. Each card has the accent icon top-right and an info popover (i icon) bottom-right that explains how the metric is calculated and gives a deterministic, value-dependent interpretation. The popover is fixed-positioned and clamped to the viewport so it never gets clipped by the sidebar or screen edges. All copy is i18n-driven (workflows.stats.tooltip.*).
* @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
* - `WorkflowStatsProps` — exported API; see TSDoc on the symbol for behavior.
* - `WorkflowStats` — 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).
* -----------------------------------------------------------------------------
* **WorkflowStatsProps**
* 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.
*
* **WorkflowStats**
* 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 { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GitFork, Users, CheckCircle, ArrowRightLeft, Layers, Clock, Info } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { WorkflowStats } from "../../lib/types";
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatDurationSec(sec: number): string {
if (sec <= 0) return "0s";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.round(sec % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
return `${s}s`;
}
function successRateColor(rate: number): string {
if (rate > 90) return "text-emerald-400";
if (rate > 70) return "text-yellow-400";
return "text-red-400";
}
// ── Deterministic interpreters - return an i18n key + params ─────────────────
// Pure rule-based mapping so the same input always yields the same explanation.
type TFn = (key: string, options?: Record<string, unknown>) => string;
type Interp = { key: string; params?: Record<string, unknown> };
function interpAvgDepth(v: number): Interp {
if (v <= 0) return { key: "stats.tooltip.depth.zero" };
if (v < 0.5) return { key: "stats.tooltip.depth.rare" };
if (v < 1.5) return { key: "stats.tooltip.depth.single" };
if (v < 2.5) return { key: "stats.tooltip.depth.multi" };
return { key: "stats.tooltip.depth.deep" };
}
function interpAvgSubagents(v: number): Interp {
if (v <= 0) return { key: "stats.tooltip.subagents.zero" };
if (v < 1) {
const oneIn = v > 0 ? Math.round(1 / v) : 0;
return { key: "stats.tooltip.subagents.lowFreq", params: { count: Math.max(2, oneIn) } };
}
if (v < 3) return { key: "stats.tooltip.subagents.moderate" };
if (v < 6) return { key: "stats.tooltip.subagents.heavy" };
return { key: "stats.tooltip.subagents.veryHeavy" };
}
function interpSuccessRate(v: number): Interp {
if (v >= 99) return { key: "stats.tooltip.success.perfect" };
if (v >= 95) return { key: "stats.tooltip.success.healthy" };
if (v >= 80) return { key: "stats.tooltip.success.acceptable" };
if (v >= 50) return { key: "stats.tooltip.success.concerning" };
return { key: "stats.tooltip.success.critical" };
}
function interpTopFlow(source: string | null, target: string | null): Interp {
if (!source || !target) return { key: "stats.tooltip.topFlow.none" };
if (source === target) {
return { key: "stats.tooltip.topFlow.selfLoop", params: { tool: source } };
}
return {
key: "stats.tooltip.topFlow.pair",
params: {
source,
target,
sourceLower: source.toLowerCase(),
targetLower: target.toLowerCase(),
},
};
}
function interpAvgCompactions(v: number): Interp {
if (v <= 0) return { key: "stats.tooltip.compactions.zero" };
if (v < 0.5) {
const oneIn = v > 0 ? Math.round(1 / v) : 0;
return { key: "stats.tooltip.compactions.lowFreq", params: { count: Math.max(2, oneIn) } };
}
if (v < 2) return { key: "stats.tooltip.compactions.moderate" };
return { key: "stats.tooltip.compactions.high" };
}
function interpAvgDuration(sec: number): Interp {
if (sec <= 0) return { key: "stats.tooltip.duration.zero" };
if (sec < 60) return { key: "stats.tooltip.duration.veryShort" };
if (sec < 5 * 60) return { key: "stats.tooltip.duration.short" };
if (sec < 30 * 60) return { key: "stats.tooltip.duration.medium" };
if (sec < 60 * 60) return { key: "stats.tooltip.duration.long" };
if (sec < 3 * 60 * 60) return { key: "stats.tooltip.duration.veryLong" };
return { key: "stats.tooltip.duration.marathon" };
}
// ── Info popover ──────────────────────────────────────────────────────────────
const POPOVER_W = 300;
const POPOVER_MARGIN = 12;
interface InfoPopoverProps {
calculationKey: string;
interp: Interp;
valueDisplay: string;
metricPhraseKey: string;
}
function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }: InfoPopoverProps) {
const { t } = useTranslation("workflows");
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [coords, setCoords] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
useLayoutEffect(() => {
if (!open) return;
const update = () => {
const btn = buttonRef.current;
const pop = popoverRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
const popH = pop?.offsetHeight ?? 240;
let left = r.right - POPOVER_W;
if (left < POPOVER_MARGIN) left = POPOVER_MARGIN;
if (left + POPOVER_W > window.innerWidth - POPOVER_MARGIN) {
left = window.innerWidth - POPOVER_W - POPOVER_MARGIN;
}
const spaceBelow = window.innerHeight - r.bottom;
const placeAbove = spaceBelow < popH + POPOVER_MARGIN && r.top > popH + POPOVER_MARGIN;
const top = placeAbove ? Math.max(POPOVER_MARGIN, r.top - popH - 8) : r.bottom + 8;
setCoords({ left, top });
};
update();
const raf = requestAnimationFrame(update);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const metricPhrase = t(metricPhraseKey);
const interpretation = t(interp.key, interp.params);
const valueMeans = t("stats.tooltip.valueMeansFmt", {
value: valueDisplay,
phrase: metricPhrase,
interpretation,
});
return (
<>
<button
ref={buttonRef}
type="button"
aria-label={t("stats.tooltip.moreInfo")}
aria-expanded={open}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
className="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-gray-600 hover:text-gray-300 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<Info className="w-4 h-4" />
</button>
{open && (
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-gray-300 pointer-events-none"
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
>
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-[#2a2a4a]">
<span className="text-base font-semibold text-gray-100 tabular-nums">
{valueDisplay}
</span>
<span className="text-[10px] uppercase tracking-wider text-gray-500">
{metricPhrase}
</span>
</div>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1">
{t("stats.tooltip.howCalc")}
</p>
<p className="text-gray-400 leading-snug mb-2.5">{t(calculationKey)}</p>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1">
{t("stats.tooltip.whatItMeans")}
</p>
<p className="text-gray-400 leading-snug">{valueMeans}</p>
</div>
)}
</>
);
}
// ── Stat card ─────────────────────────────────────────────────────────────────
interface StatCardProps {
label: string;
value: string;
icon: LucideIcon;
accentClass?: string;
calculationKey: string;
interp: Interp;
metricPhraseKey: string;
}
function StatCard({
label,
value,
icon: Icon,
accentClass = "text-accent",
calculationKey,
interp,
metricPhraseKey,
}: StatCardProps) {
return (
<div className="bg-surface-2 border border-border rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider leading-none">
{label}
</span>
<Icon className={`w-4 h-4 flex-shrink-0 ${accentClass}`} />
</div>
<div className="flex items-end justify-between gap-2">
<span
className={`text-2xl font-semibold leading-none truncate ${accentClass}`}
title={value}
>
{value}
</span>
<InfoPopover
calculationKey={calculationKey}
interp={interp}
valueDisplay={value}
metricPhraseKey={metricPhraseKey}
/>
</div>
</div>
);
}
// ── Public component ──────────────────────────────────────────────────────────
export interface WorkflowStatsProps {
stats: WorkflowStats;
}
export function WorkflowStats({ stats }: WorkflowStatsProps) {
const { t } = useTranslation("workflows");
// t is referenced for translation prefix consistency.
void (t as TFn);
const topFlow = stats.topFlow;
const topFlowLabel = topFlow ? `${topFlow.source}${topFlow.target}` : "-";
const srColor = successRateColor(stats.successRate);
return (
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-6 gap-3">
<StatCard
label={t("stats.avgAgentDepth")}
value={stats.avgDepth.toFixed(1)}
icon={GitFork}
accentClass="text-indigo-400"
calculationKey="stats.tooltip.calc.depth"
interp={interpAvgDepth(stats.avgDepth)}
metricPhraseKey="stats.tooltip.phrase.depth"
/>
<StatCard
label={t("stats.avgSubagentsPerSession")}
value={stats.avgSubagents.toFixed(1)}
icon={Users}
accentClass="text-blue-400"
calculationKey="stats.tooltip.calc.subagents"
interp={interpAvgSubagents(stats.avgSubagents)}
metricPhraseKey="stats.tooltip.phrase.subagents"
/>
<StatCard
label={t("stats.agentSuccessRate")}
value={`${stats.successRate.toFixed(1)}%`}
icon={CheckCircle}
accentClass={srColor}
calculationKey="stats.tooltip.calc.success"
interp={interpSuccessRate(stats.successRate)}
metricPhraseKey="stats.tooltip.phrase.success"
/>
<StatCard
label={t("stats.mostCommonFlow")}
value={topFlowLabel}
icon={ArrowRightLeft}
accentClass="text-violet-400"
calculationKey="stats.tooltip.calc.topFlow"
interp={interpTopFlow(topFlow?.source ?? null, topFlow?.target ?? null)}
metricPhraseKey="stats.tooltip.phrase.topFlow"
/>
<StatCard
label={t("stats.avgCompactions")}
value={stats.avgCompactions.toFixed(1)}
icon={Layers}
accentClass="text-cyan-400"
calculationKey="stats.tooltip.calc.compactions"
interp={interpAvgCompactions(stats.avgCompactions)}
metricPhraseKey="stats.tooltip.phrase.compactions"
/>
<StatCard
label={t("stats.avgDuration")}
value={formatDurationSec(stats.avgDurationSec)}
icon={Clock}
accentClass="text-amber-400"
calculationKey="stats.tooltip.calc.duration"
interp={interpAvgDuration(stats.avgDurationSec)}
metricPhraseKey="stats.tooltip.phrase.duration"
/>
</div>
);
}
@@ -0,0 +1,142 @@
/**
* @file Render + interaction tests for WorkflowRunsPanel (controlled mode):
* empty state, collapsed header, expand → phase chips + per-agent table +
* results (phase markers excluded), phase-filter toggling, per-result expand to
* full content, and the running-run indicator.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { WorkflowRunsPanel } from "../WorkflowRunsPanel";
import type { WorkflowRun } from "../../../lib/types";
function makeRun(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
return {
run_id: "wf_x",
session_id: "s1",
task_id: null,
name: "review-changes",
status: "completed",
default_model: "claude-opus-4-8",
started_at: "2026-06-14T00:00:00.000Z",
ended_at: "2026-06-14T00:00:05.000Z",
duration_ms: 5000,
agent_count: 3,
total_tokens: 48000,
total_tool_calls: 9,
phases: [{ title: "Scout" }, { title: "Verify" }],
progress: [
{ type: "workflow_phase", index: 1, title: "Scout" },
{
type: "workflow_agent",
agentId: "a1",
label: "scout:starship",
phaseTitle: "Scout",
state: "done",
tokens: 20000,
toolCalls: 5,
durationMs: 1000,
lastToolName: "Read",
resultPreview: '{"claim":"SCOUTCLAIM about starship"}',
},
{
type: "workflow_agent",
agentId: "a2",
label: "verify:starship",
phaseTitle: "Verify",
state: "done",
tokens: 18000,
toolCalls: 3,
durationMs: 900,
resultPreview: '{"verdict":"confirmed","note":"VERIFYNOTE"}',
},
{
type: "workflow_agent",
agentId: "a3",
label: "scout:starlink",
phaseTitle: "Scout",
state: "running",
tokens: 1000,
toolCalls: 1,
},
],
script_path: null,
journal_path: null,
source: "journal",
created_at: "2026-06-14T00:00:00.000Z",
updated_at: "2026-06-14T00:00:05.000Z",
...overrides,
};
}
const renderPanel = (runs: WorkflowRun[]) =>
render(
<MemoryRouter>
<WorkflowRunsPanel runs={runs} hideSessionLink />
</MemoryRouter>
);
const runHeader = () => screen.getByRole("button", { name: /review-changes/ });
const buttonByText = (text: string) =>
screen.getAllByRole("button").find((b) => (b.textContent || "").trim() === text);
describe("WorkflowRunsPanel", () => {
it("shows the empty state with no runs", () => {
renderPanel([]);
expect(screen.getByText(/No dynamic workflows yet/i)).toBeInTheDocument();
});
it("renders a collapsed run header; the agent table is hidden until expanded", () => {
renderPanel([makeRun()]);
expect(screen.getByText("review-changes")).toBeInTheDocument();
expect(screen.getByText("Completed")).toBeInTheDocument();
expect(screen.getByText("claude-opus-4-8")).toBeInTheDocument();
// collapsed → inner agents not rendered yet
expect(screen.queryByText("scout:starship")).not.toBeInTheDocument();
});
it("expands to phase chips + per-agent rows, excluding phase markers", () => {
renderPanel([makeRun()]);
fireEvent.click(runHeader());
// 3 workflow_agent entries → present; the workflow_phase marker is NOT a row
expect(screen.getAllByText("scout:starship").length).toBeGreaterThan(0);
expect(screen.getAllByText("verify:starship").length).toBeGreaterThan(0);
expect(screen.getByText("scout:starlink")).toBeInTheDocument();
// phase filter chips exist as buttons
expect(buttonByText("Scout")).toBeTruthy();
expect(buttonByText("Verify")).toBeTruthy();
});
it("filters rows by phase when a phase chip is clicked, then clears", () => {
renderPanel([makeRun()]);
fireEvent.click(runHeader());
fireEvent.click(buttonByText("Scout")!);
// Verify-phase agent is filtered out everywhere; Scout agents remain
expect(screen.queryByText("verify:starship")).not.toBeInTheDocument();
expect(screen.getByText("scout:starlink")).toBeInTheDocument();
// clicking the same chip again clears the filter → Verify agent returns
fireEvent.click(buttonByText("Scout")!);
expect(screen.getAllByText("verify:starship").length).toBeGreaterThan(0);
});
it("expands a result row to its full, un-truncated content", () => {
renderPanel([makeRun()]);
fireEvent.click(runHeader());
// collapsed result shows the humanized excerpt, not the raw JSON keys
expect(screen.queryByText(/confirmed/)).not.toBeInTheDocument();
const resultBtn = screen
.getAllByRole("button")
.find((b) => /verify:starship/.test(b.textContent || "") && b.getAttribute("aria-expanded"));
fireEvent.click(resultBtn!);
// full content (pretty-printed JSON) now visible → the "verdict" key shows
expect(screen.getByText(/confirmed/)).toBeInTheDocument();
});
it("marks a running workflow as running", () => {
renderPanel([makeRun({ status: "running", name: "live-run" })]);
const header = screen.getByRole("button", { name: /live-run/ });
expect(within(header).getByText("Running")).toBeInTheDocument();
});
});
@@ -0,0 +1,140 @@
/**
* @file Tests for the lazy full-transcript fetch in WorkflowRunsPanel: expanding
* a result row fetches the agent's complete prompt/result (the run journal only
* carries truncated "…" previews), with a graceful fallback to the journal
* teaser when the fetch fails. Also unit-tests extractPromptResult.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
const { transcriptMock } = vi.hoisted(() => ({ transcriptMock: vi.fn() }));
vi.mock("../../../lib/api", () => ({
api: { sessions: { transcript: transcriptMock } },
}));
import { WorkflowRunsPanel, extractPromptResult } from "../WorkflowRunsPanel";
import type { WorkflowRun } from "../../../lib/types";
import type { TranscriptMessage } from "../../../lib/types";
function makeRun(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
return {
run_id: "wf_x",
session_id: "s1",
task_id: null,
name: "review-changes",
status: "completed",
default_model: "claude-opus-4-8",
started_at: "2026-06-14T00:00:00.000Z",
ended_at: "2026-06-14T00:00:05.000Z",
duration_ms: 5000,
agent_count: 1,
total_tokens: 20000,
total_tool_calls: 5,
phases: [{ title: "Verify" }],
progress: [
{ type: "workflow_phase", index: 1, title: "Verify" },
{
type: "workflow_agent",
agentId: "a1",
label: "verify:starship",
phaseTitle: "Verify",
state: "done",
tokens: 18000,
toolCalls: 3,
durationMs: 900,
promptPreview: "Verify the claim…",
resultPreview: '{"verdict":"confirmed","note":"VERIFYNOTE"}',
},
],
script_path: null,
journal_path: null,
source: "journal",
created_at: "2026-06-14T00:00:00.000Z",
updated_at: "2026-06-14T00:00:05.000Z",
...overrides,
};
}
const renderPanel = (runs: WorkflowRun[]) =>
render(
<MemoryRouter>
<WorkflowRunsPanel runs={runs} hideSessionLink />
</MemoryRouter>
);
const runHeader = () => screen.getByRole("button", { name: /review-changes/ });
const resultButton = () =>
screen
.getAllByRole("button")
.find((b) => /verify:starship/.test(b.textContent || "") && b.getAttribute("aria-expanded"))!;
beforeEach(() => {
transcriptMock.mockReset();
});
const msg = (type: "user" | "assistant", text: string): TranscriptMessage =>
({ type, content: [{ type: "text", text }] }) as TranscriptMessage;
describe("extractPromptResult", () => {
it("takes the first user text as prompt and the last assistant text as result", () => {
const out = extractPromptResult([
msg("user", "do the task"),
msg("assistant", "thinking…"),
msg("assistant", "FINAL ANSWER"),
]);
expect(out).toEqual({ prompt: "do the task", result: "FINAL ANSWER" });
});
it("returns empty strings for missing turns (e.g. schema-mode tool-only final)", () => {
expect(extractPromptResult([])).toEqual({ prompt: "", result: "" });
expect(extractPromptResult([msg("user", "only a prompt")])).toEqual({
prompt: "only a prompt",
result: "",
});
});
});
describe("WorkflowRunsPanel lazy transcript fetch", () => {
it("fetches the full transcript on expand and renders it instead of the teaser", async () => {
const LONG_RESULT =
"CONFIRMED across every primary source. " +
"The full reasoning runs well past the truncated journal preview. ".repeat(6).trim();
transcriptMock.mockResolvedValue({
messages: [msg("user", "Verify the starship claim in full."), msg("assistant", LONG_RESULT)],
});
renderPanel([makeRun()]);
fireEvent.click(runHeader());
fireEvent.click(resultButton());
// Called with the run_id so the route can resolve the nested transcript.
expect(transcriptMock).toHaveBeenCalledWith("s1", {
agent_id: "a1",
run_id: "wf_x",
limit: 200,
});
// Full fetched text appears (async) in a <pre>, and the prompt is the
// fetched one - not the short journal teaser.
const resultPre = await screen.findByText(
(_, el) => el?.tagName === "PRE" && (el.textContent || "").includes("runs well past"),
{ selector: "pre" }
);
expect(resultPre).toBeInTheDocument();
expect(screen.getByText("Verify the starship claim in full.")).toBeInTheDocument();
});
it("falls back to the journal preview when the fetch fails", async () => {
transcriptMock.mockRejectedValue(new Error("network"));
renderPanel([makeRun()]);
fireEvent.click(runHeader());
fireEvent.click(resultButton());
// The pretty-printed journal preview (with the "confirmed" key) is shown.
expect(await screen.findByText(/confirmed/)).toBeInTheDocument();
});
});
@@ -0,0 +1,71 @@
/**
* @file Unit tests for friendlyPreview - turns an agent's raw (often truncated)
* JSON result preview into a human-readable excerpt for the Workflow Runs panel.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { friendlyPreview, fullPreview } from "../WorkflowRunsPanel";
describe("friendlyPreview", () => {
it("returns empty for nullish/empty input", () => {
expect(friendlyPreview(null)).toBe("");
expect(friendlyPreview(undefined)).toBe("");
expect(friendlyPreview("")).toBe("");
});
it("prefers a known content field (pitch) over earlier short fields", () => {
const raw =
'{"language":"Python","pitch":"If you learn one language first in 2026, make it Python."}';
expect(friendlyPreview(raw)).toBe("If you learn one language first in 2026, make it Python.");
});
it("extracts the first claim from a findings array", () => {
const raw =
'{"angle":"starship","findings":[{"claim":"SpaceX flew Starship Flight 12 on May 22, 2026.","source":"x","confidence":"high"}]}';
expect(friendlyPreview(raw)).toBe("SpaceX flew Starship Flight 12 on May 22, 2026.");
});
it("handles a TRUNCATED json blob (no closing quote/brace) via the keyed field", () => {
const raw =
'{"angle":"starlink","findings":[{"claim":"As of June 2026, Starlink has roughly 10,500 active satellites in orbit and a';
// keyed match stops at end-of-string (no closing quote), so it returns the partial claim
expect(friendlyPreview(raw)).toContain("As of June 2026, Starlink has roughly 10,500");
expect(friendlyPreview(raw)).not.toContain('{"angle"');
});
it("falls back to the first substantial quoted string when no known key matches", () => {
const raw = '{"x":"ab","y":"this is a sufficiently long quoted value to surface"}';
expect(friendlyPreview(raw)).toBe("this is a sufficiently long quoted value to surface");
});
it("de-JSONs a blob with no good string, stripping structural punctuation", () => {
const raw = '{"a":1,"b":[2,3]}';
const out = friendlyPreview(raw);
expect(out).not.toContain("{");
expect(out).not.toContain('"');
expect(out).not.toContain("[");
});
it("passes plain prose through unchanged", () => {
const raw = "All four claims confirmed against primary sources.";
expect(friendlyPreview(raw)).toBe("All four claims confirmed against primary sources.");
});
});
describe("fullPreview", () => {
it("pretty-prints valid JSON", () => {
const out = fullPreview('{"a":1,"b":{"c":2}}');
expect(out).toBe('{\n "a": 1,\n "b": {\n "c": 2\n }\n}');
});
it("returns truncated/invalid JSON verbatim", () => {
const raw = '{"claim":"partial value with no close';
expect(fullPreview(raw)).toBe(raw);
});
it("returns empty for nullish input", () => {
expect(fullPreview(null)).toBe("");
expect(fullPreview(undefined)).toBe("");
});
});