feat(workflows): show lane pipelines, drop the deep analytics charts

The Workflows page rendered eleven charts of Claude Code harness internals
(collaboration network, subagent effectiveness, patterns, model delegation,
error propagation, concurrency, complexity, compaction). Too specialized to
read and nothing to act on, so they are gone along with their i18n keys.

What is left is what a reader can act on: headline stats, Workflow-tool runs,
the spawning DAG, the tool flow and the per-session drill-in — plus a new
first section for this dashboard's OWN pipelines. LanePipelinePanel renders a
horizontally scrolling lane strip and the selected lane's PipelineMap,
reusing the Workspace components so a stage looks identical on both pages.

The panel is read-only: the console-never-writes-a-stage boundary applies
here too, so every lane action stays on the Workspace page. The workflows API
response shape is untouched.

Closes #6
This commit is contained in:
2026-08-26 10:05:35 +07:00
parent bcfeeb5140
commit 4a0f99583d
16 changed files with 351 additions and 4766 deletions
@@ -1,638 +0,0 @@
/**
* @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.
*
* ## 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.
* - `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-fg-secondary">{t("pipeline.noData")}</p>
<p className="text-xs text-fg-muted 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-fg-muted 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-fg-muted">{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-fg-muted">{t("pipeline.legendDesc")}</span>
</div>
</div>
</div>
);
}
@@ -1,424 +0,0 @@
/**
* @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.
*
* ## 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.
* - `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-fg-muted uppercase tracking-wider leading-tight">
{label}
</span>
{sub && <span className="text-[11px] text-fg-muted 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-fg-muted">
<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-fg-muted 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-400"
/>
<StatBox
label={t("compaction.peakSession")}
value={peak.toLocaleString()}
accent="text-status-success"
/>
</div>
{/* Histogram: sessions by compaction count */}
<div className="w-full overflow-hidden">
<p className="text-xs font-medium text-fg-muted 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-fg-muted 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-fg-primary">{tip.title}</div>
<div className="mt-0.5 text-fg-secondary">{tip.detail}</div>
</div>
)}
</div>
);
}
@@ -1,365 +0,0 @@
/**
* @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.
*
* ## 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.
* - `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 0–1 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-fg-secondary truncate block group-hover:text-fg-secondary 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-fg-muted 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-fg-muted"
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-fg-secondary">{t("concurrency.noData")}</p>
<p className="text-xs text-fg-muted 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-fg-muted uppercase tracking-wider">
{t("concurrency.sessions")}
</span>
<span className="text-[10px] text-fg-muted tabular-nums">
{maxCount}
{t("concurrency.max")}
</span>
</div>
<div className="flex-shrink-0 w-[72px] text-[10px] text-fg-muted 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;
}
@@ -1,270 +0,0 @@
/**
* @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.
*
* ## 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.
* - `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-status-success/10 border border-status-success/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-status-success font-medium">
{t("errorPropagation.noErrors")}
</span>
<span className="text-xs text-fg-muted">{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-status-danger/5 border border-status-danger/15">
<div className="flex-shrink-0 min-w-[2.75rem] h-10 px-2 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center">
<span className="text-[13px] font-bold text-status-danger tabular-nums whitespace-nowrap">
{errorRatePct}%
</span>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-status-danger">
{t("errorPropagation.sessionsErrorSummary", {
errorSessions: data.sessionsWithErrors,
totalSessions: data.totalSessions,
})}
</p>
<p className="text-[11px] text-fg-muted 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-fg-muted 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-fg-muted 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-fg-muted 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-fg-secondary 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-status-danger/60"
style={{ width: `${Math.max(pct, 8)}%` }}
/>
</div>
<span className="text-[11px] font-semibold text-status-danger 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-fg-muted 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-status-warning/5 border border-status-warning/10 hover:border-status-warning/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-fg-secondary truncate flex-1 min-w-0"
title={e.summary}
>
{e.summary}
</span>
<span className="flex-shrink-0 text-[11px] font-semibold text-status-warning tabular-nums">
{e.count}x
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,108 @@
/**
* @file LanePipelinePanel.tsx
* @description Read-only view of this dashboard's OWN pipelines on the Workflows
* page: a horizontally scrolling strip of lane tiles, and the selected lane's
* pipeline map underneath. It reuses the Workspace's `LaneStripCard` and
* `PipelineMap` so a stage reads identically on both pages. Read-only on
* purpose — the console-never-writes-a-stage boundary applies here too, so this
* panel only observes; every lane action stays on the Workspace page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import type { Lane } from "../../lib/types";
import LaneStripCard from "../lanes/LaneStripCard";
import PipelineMap from "../lanes/PipelineMap";
/**
* Lane strip plus the selected lane's pipeline map.
* @returns The panel element; an empty-state hint when no lane exists yet.
*/
export function LanePipelinePanel() {
const { t } = useTranslation(["workflows", "lanes"]);
const [lanes, setLanes] = useState<Lane[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const r = await api.lanes.list();
setLanes(r.lanes);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("workflows:failedLoad"));
}
}, [t]);
useEffect(() => {
void refresh();
}, [refresh]);
// Lanes move as hooks land; debounce so a burst of events costs one fetch.
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const unsub = eventBus.subscribe(() => {
clearTimeout(timer);
timer = setTimeout(() => void refresh(), 3000);
});
return () => {
unsub();
clearTimeout(timer);
};
}, [refresh]);
// Follow the list rather than pinning an id: a lane can be removed elsewhere.
const selected = lanes.find((l) => l.id === selectedId) ?? lanes[0] ?? null;
if (error) return <p className="text-xs text-status-danger">{error}</p>;
if (!lanes.length) {
return (
<p className="text-sm text-fg-muted">
{t("lanes:emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
</p>
);
}
return (
<div className="space-y-3">
<div data-testid="workflows-lane-strip" className="flex flex-row gap-2 overflow-x-auto pb-1">
{lanes.map((l) => (
<LaneStripCard
key={l.id}
lane={l}
selected={selected?.id === l.id}
onSelect={() => setSelectedId(l.id)}
/>
))}
</div>
{selected && (
<div data-testid="workflows-lane-pipeline" className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-semibold text-fg-primary">
{t("lanes:laneHeader", {
id: selected.id,
title: selected.title || selected.cwd,
pipeline: selected.pipeline_name,
})}
</span>
<Link
to="/run"
className="ml-auto inline-flex items-center gap-1 text-xs text-fg-muted hover:text-accent"
>
{t("workflows:lanes.openWorkspace")}
<ExternalLink className="h-3 w-3" />
</Link>
</div>
<PipelineMap nodes={selected.pipeline_nodes} detectedSignal={selected.detected_signal} />
</div>
)}
</div>
);
}
@@ -1,532 +0,0 @@
/**
* @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.
*
* ## 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.
* - `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-fg-muted">
<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);
}
@@ -1,394 +0,0 @@
/**
* @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.
*
* ## 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.
* - `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-fg-secondary 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-fg-primary 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-fg-secondary">
<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-fg-muted">
{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-fg-muted"
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-fg-secondary">{t("complexity.noData")}</p>
<p className="text-xs text-fg-muted 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>
);
}
@@ -1,386 +0,0 @@
/**
* @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.
*
* ## 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.
* - `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-fg-muted 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-fg-muted 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-fg-secondary whitespace-nowrap pointer-events-none"
style={{ left: pos.left, top: pos.top }}
>
<span className="font-medium">{label}</span>
<span className="text-fg-secondary 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-fg-secondary tabular-nums truncate w-full text-center">
{value}
</span>
<span className="text-[9px] text-fg-muted 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-fg-secondary 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-fg-muted 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-fg-muted 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>
);
}
@@ -1,395 +0,0 @@
/**
* @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.
*
* ## 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.
* - `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-fg-muted" />
)}
</span>
))}
{overflow > 0 && (
<span className="inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium bg-surface-3/50 text-fg-secondary border border-border-light/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-fg-primary">{count.toLocaleString()}</p>
<p className="text-xs text-fg-muted">
{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-fg-muted 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-fg-muted 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-fg-muted" />
)}
</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-fg-muted 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-fg-secondary 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-fg-secondary 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-fg-primary tabular-nums">{value}</p>
<p className="text-[10px] text-fg-muted 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-fg-primary">{count.toLocaleString()}</p>
<p className="text-xs text-fg-muted">
{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-fg-muted" />
</div>
<p className="text-sm font-medium text-fg-secondary">{t("patterns.noData")}</p>
<p className="text-xs text-fg-muted 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-fg-secondary 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,115 @@
/**
* @file Tests for the Workflows page's lane pipeline panel: it lists every lane
* as a tile, shows the first lane's pipeline before anything is clicked, and
* swaps the map when another lane is selected. A wrong selection would show one
* lane's stage under another lane's name.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LanePipelinePanel } from "../LanePipelinePanel";
const { LANES } = vi.hoisted(() => {
const laneStub = (id: number, stageId: string) => ({
id,
title: `lane-${id}`,
cwd: `/workspace/lane-${id}`,
branch: null,
kind: "adopted",
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
stage: stageId,
stage_since: null,
status: "idle",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Default",
pipeline_nodes: [
{
id: stageId,
label: stageId,
icon: "🧭",
gate: false,
state: "current",
},
],
progress: 25,
stage_seconds: null,
last_event_seconds: null,
liveness: "idle",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
active_feature_id: null,
});
return { LANES: [laneStub(1, "plan"), laneStub(2, "tests")] };
});
const listMock = vi.fn();
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { lanes: { list: () => listMock() } } };
});
function renderPanel() {
return render(
<MemoryRouter>
<LanePipelinePanel />
</MemoryRouter>
);
}
describe("LanePipelinePanel", () => {
it("shows the first lane's pipeline before any tile is clicked", async () => {
listMock.mockResolvedValue({
lanes: LANES,
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
});
renderPanel();
expect(await screen.findByTestId("lane-tile-1")).toHaveAttribute("data-selected", "true");
expect(screen.getByTestId("pipeline-node-plan")).toBeInTheDocument();
expect(screen.queryByTestId("pipeline-node-tests")).not.toBeInTheDocument();
});
it("swaps the pipeline map when another lane is selected", async () => {
listMock.mockResolvedValue({
lanes: LANES,
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
});
renderPanel();
fireEvent.click(await screen.findByTestId("lane-tile-2"));
expect(screen.getByTestId("pipeline-node-tests")).toBeInTheDocument();
expect(screen.queryByTestId("pipeline-node-plan")).not.toBeInTheDocument();
});
it("renders the CLI hint when no lane exists", async () => {
listMock.mockResolvedValue({
lanes: [],
counts: { total: 0, running: 0, needs_you: 0, dead: 0 },
});
renderPanel();
expect(await screen.findByText(/ccam lanes add/)).toBeInTheDocument();
expect(screen.queryByTestId("workflows-lane-strip")).not.toBeInTheDocument();
});
it("surfaces a failed fetch instead of an empty strip", async () => {
listMock.mockRejectedValue(new Error("lanes unreachable"));
renderPanel();
expect(await screen.findByText("lanes unreachable")).toBeInTheDocument();
});
});