feat(workflows): pipeline pass rates and skill/plugin usage
Restores the five analytics sections that turned out to be worth keeping (patterns, model delegation, error propagation, session complexity, compaction impact) and reworks what the page opens on. The lane panel no longer mirrors the Workspace's live lane strip. It now answers the question the strip cannot: how often each pipeline node actually passes. GET /api/lanes/pipeline-stats aggregates every stage record of every lane AND every archived feature — a lane's own `stages` only holds its current feature, so without the archive the history resets each time a lane starts new work. Evidence-backed passes are counted apart from bare claims, the same line the pipeline map draws between `done` and `passed-no-evidence`. Beside it, GET /api/workflows/skill-usage reports which skills and plugins the sessions actually reach for. Only PreToolUse rows are counted: every call also emits a PostToolUse, and counting both would double every number. A plugin's total includes calls to the MCP tools it ships, so one used purely through MCP does not read as unused. Still dropped from issue #6: the agent collaboration network and the subagent effectiveness scorecards.
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,41 @@
|
||||
/**
|
||||
* @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.
|
||||
* @description How often each node of this dashboard's OWN pipelines actually
|
||||
* gets passed, aggregated over every lane and every archived feature by
|
||||
* `GET /api/lanes/pipeline-stats`. One row per node: a bar split into passed
|
||||
* with evidence / passed without / failed, plus the raw counts, so a stage that
|
||||
* everyone claims but nobody proves is visible at a glance. Read-only — the
|
||||
* live per-lane view and every lane action stay 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";
|
||||
import type { PipelineStats } from "../../lib/types";
|
||||
|
||||
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
||||
function pct(n: number, total: number): number {
|
||||
return total > 0 ? (n / total) * 100 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lane strip plus the selected lane's pipeline map.
|
||||
* @returns The panel element; an empty-state hint when no lane exists yet.
|
||||
* Per-node pass statistics for the selected pipeline template.
|
||||
* @returns The panel element, or an empty-state hint when nothing was recorded.
|
||||
*/
|
||||
export function LanePipelinePanel() {
|
||||
const { t } = useTranslation(["workflows", "lanes"]);
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const { t } = useTranslation("workflows");
|
||||
const [data, setData] = useState<PipelineStats | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.lanes.list();
|
||||
setLanes(r.lanes);
|
||||
setData(await api.lanes.pipelineStats());
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("workflows:failedLoad"));
|
||||
setError(err instanceof Error ? err.message : t("failedLoad"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
@@ -43,7 +43,7 @@ export function LanePipelinePanel() {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Lanes move as hooks land; debounce so a burst of events costs one fetch.
|
||||
// Stages move as agents declare them; debounce so a burst costs one fetch.
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const unsub = eventBus.subscribe(() => {
|
||||
@@ -56,53 +56,106 @@ export function LanePipelinePanel() {
|
||||
};
|
||||
}, [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 (!data) return null;
|
||||
|
||||
if (!lanes.length) {
|
||||
// Follow the list rather than pinning an id: templates can come and go.
|
||||
const selected = data.pipelines.find((p) => p.id === selectedId) ?? data.pipelines[0] ?? null;
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t("lanes:emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
|
||||
{t("lanes.empty")} <code>ccam lanes add --cwd $(pwd)</code>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const maxEntered = Math.max(...selected.nodes.map((n) => n.entered), 1);
|
||||
|
||||
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 className="flex flex-wrap items-center gap-2">
|
||||
{data.pipelines.length > 1 && (
|
||||
<select
|
||||
data-testid="pipeline-stats-picker"
|
||||
aria-label={t("lanes.pipelineLabel")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
|
||||
value={selected.id}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
>
|
||||
{data.pipelines.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<span className="text-xs text-fg-muted">{t("lanes.runs", { count: selected.runs })}</span>
|
||||
<div className="ml-auto flex items-center gap-3 text-[10px] text-fg-muted">
|
||||
<LegendSwatch className="bg-status-success" label={t("lanes.legendEvidence")} />
|
||||
<LegendSwatch className="bg-status-warning" label={t("lanes.legendClaimed")} />
|
||||
<LegendSwatch className="bg-status-danger" label={t("lanes.legendFailed")} />
|
||||
</div>
|
||||
</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"
|
||||
<div className="space-y-1.5">
|
||||
{selected.nodes.map((n) => {
|
||||
const rate = pct(n.passed, n.entered);
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
data-testid={`pipeline-stat-${n.id}`}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
{t("workflows:lanes.openWorkspace")}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<PipelineMap nodes={selected.pipeline_nodes} detectedSignal={selected.detected_signal} />
|
||||
</div>
|
||||
)}
|
||||
<span className="w-36 shrink-0 truncate text-fg-secondary" title={n.label}>
|
||||
<span className="mr-1">{n.icon}</span>
|
||||
{n.label}
|
||||
</span>
|
||||
|
||||
{/* Bars are scaled against the busiest node, not against each
|
||||
node's own total, so a node reached twice never looks as
|
||||
travelled as one reached fifty times. */}
|
||||
<div className="flex h-2.5 flex-1 overflow-hidden rounded-full bg-surface-3">
|
||||
<div
|
||||
className="h-full bg-status-success"
|
||||
style={{ width: `${pct(n.withEvidence, maxEntered)}%` }}
|
||||
title={t("lanes.legendEvidence")}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-status-warning"
|
||||
style={{ width: `${pct(n.passed - n.withEvidence, maxEntered)}%` }}
|
||||
title={t("lanes.legendClaimed")}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-status-danger"
|
||||
style={{ width: `${pct(n.failed, maxEntered)}%` }}
|
||||
title={t("lanes.legendFailed")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="w-28 shrink-0 text-right tabular-nums text-fg-muted">
|
||||
{n.entered === 0 ? (
|
||||
t("lanes.never")
|
||||
) : (
|
||||
<>
|
||||
<span className="text-fg-secondary">{n.passed}</span>/{n.entered}
|
||||
<span className="ml-1">({Math.round(rate)}%)</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendSwatch({ className, label }: { className: string; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={`h-2 w-2 rounded-sm ${className}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file SkillUsagePanel.tsx
|
||||
* @description Which skills and plugins actually get used, from
|
||||
* `GET /api/workflows/skill-usage`: plugin totals as chips (skill calls + the
|
||||
* plugin's own MCP tool calls) over a ranked list of individual skills with
|
||||
* their call count and how many sessions reached for them. A skill that is
|
||||
* installed but never invoked simply never appears — the panel reports use, not
|
||||
* inventory.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import type { SkillUsage } from "../../lib/types";
|
||||
|
||||
export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [data, setData] = useState<SkillUsage | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setData(await api.workflows.skillUsage(statusFilter));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("failedLoad"));
|
||||
}
|
||||
}, [statusFilter, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const unsub = eventBus.subscribe(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void refresh(), 3000);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
if (error) return <p className="text-xs text-status-danger">{error}</p>;
|
||||
if (!data) return null;
|
||||
if (!data.skills.length && !data.plugins.length) {
|
||||
return <p className="text-sm text-fg-muted">{t("skillUsage.empty")}</p>;
|
||||
}
|
||||
|
||||
const topCount = Math.max(...data.skills.map((s) => s.count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{data.plugins.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("skillUsage.plugins")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.plugins.map((p) => (
|
||||
<span
|
||||
key={p.name}
|
||||
data-testid={`plugin-usage-${p.name}`}
|
||||
title={t("skillUsage.pluginBreakdown", {
|
||||
skills: p.skills,
|
||||
skillCalls: p.skillCalls,
|
||||
mcpCalls: p.mcpCalls,
|
||||
})}
|
||||
className="rounded-full border border-border bg-surface-2 px-2.5 py-1 text-xs text-fg-secondary"
|
||||
>
|
||||
{p.name}
|
||||
<span className="ml-1.5 tabular-nums text-accent">{p.skillCalls + p.mcpCalls}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("skillUsage.skills")}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{data.skills.map((s) => (
|
||||
<div
|
||||
key={s.name}
|
||||
data-testid={`skill-usage-${s.name}`}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<span className="w-56 shrink-0 truncate text-fg-secondary" title={s.name}>
|
||||
{s.name}
|
||||
</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-surface-3">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent"
|
||||
style={{ width: `${(s.count / topCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-32 shrink-0 text-right tabular-nums text-fg-muted">
|
||||
{t("skillUsage.calls", { count: s.count })} ·{" "}
|
||||
{t("skillUsage.sessions", { count: s.sessions })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +1,106 @@
|
||||
/**
|
||||
* @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.
|
||||
* @file Tests for the Workflows page's pipeline pass-rate panel: it renders one
|
||||
* row per node of the busiest pipeline, distinguishes a proved pass from a
|
||||
* merely claimed one, and switches templates through the picker. Conflating
|
||||
* evidence with a bare claim is the failure that matters here — it would make
|
||||
* an undocumented stage look verified.
|
||||
* @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 { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
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();
|
||||
const statsMock = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { lanes: { list: () => listMock() } } };
|
||||
return { ...actual, api: { lanes: { pipelineStats: () => statsMock() } } };
|
||||
});
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<LanePipelinePanel />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
const node = (id: string, over: Record<string, number> = {}) => ({
|
||||
id,
|
||||
label: id,
|
||||
icon: "🧭",
|
||||
gate: false,
|
||||
entered: 0,
|
||||
passed: 0,
|
||||
withEvidence: 0,
|
||||
failed: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
const STATS = {
|
||||
pipelines: [
|
||||
{
|
||||
id: "default",
|
||||
name: "Default feature pipeline",
|
||||
runs: 4,
|
||||
nodes: [
|
||||
node("plan", { entered: 4, passed: 4, withEvidence: 4 }),
|
||||
node("implement", { entered: 4, passed: 4, withEvidence: 0 }),
|
||||
node("tests", { entered: 3, passed: 2, withEvidence: 2, failed: 1 }),
|
||||
node("ship"),
|
||||
],
|
||||
},
|
||||
{ id: "hotfix", name: "Hotfix", runs: 1, nodes: [node("patch", { entered: 1, passed: 1 })] },
|
||||
],
|
||||
};
|
||||
|
||||
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();
|
||||
it("shows pass count, entries and rate per node of the busiest pipeline", async () => {
|
||||
statsMock.mockResolvedValue(STATS);
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
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();
|
||||
const tests = await screen.findByTestId("pipeline-stat-tests");
|
||||
expect(within(tests).getByText("2")).toBeInTheDocument();
|
||||
expect(tests).toHaveTextContent("2/3");
|
||||
expect(tests).toHaveTextContent("67%");
|
||||
});
|
||||
|
||||
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();
|
||||
it("draws a claimed pass in a different colour from a proved one", async () => {
|
||||
statsMock.mockResolvedValue(STATS);
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("lane-tile-2"));
|
||||
|
||||
expect(screen.getByTestId("pipeline-node-tests")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pipeline-node-plan")).not.toBeInTheDocument();
|
||||
const proved = await screen.findByTestId("pipeline-stat-plan");
|
||||
const claimed = screen.getByTestId("pipeline-stat-implement");
|
||||
// Same counts, different bar: plan is all evidence, implement is all claim.
|
||||
expect(proved.querySelector(".bg-status-success")).toHaveStyle({ width: "100%" });
|
||||
expect(claimed.querySelector(".bg-status-success")).toHaveStyle({ width: "0%" });
|
||||
expect(claimed.querySelector(".bg-status-warning")).toHaveStyle({ width: "100%" });
|
||||
});
|
||||
|
||||
it("renders the CLI hint when no lane exists", async () => {
|
||||
listMock.mockResolvedValue({
|
||||
lanes: [],
|
||||
counts: { total: 0, running: 0, needs_you: 0, dead: 0 },
|
||||
it("marks a node that was never reached instead of showing 0%", async () => {
|
||||
statsMock.mockResolvedValue(STATS);
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
const ship = await screen.findByTestId("pipeline-stat-ship");
|
||||
expect(ship).not.toHaveTextContent("%");
|
||||
});
|
||||
|
||||
it("switches templates through the picker", async () => {
|
||||
statsMock.mockResolvedValue(STATS);
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
fireEvent.change(await screen.findByTestId("pipeline-stats-picker"), {
|
||||
target: { value: "hotfix" },
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByTestId("pipeline-stat-patch")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pipeline-stat-plan")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the CLI hint when nothing has been recorded", async () => {
|
||||
statsMock.mockResolvedValue({ pipelines: [] });
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
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();
|
||||
it("surfaces a failed fetch", async () => {
|
||||
statsMock.mockRejectedValue(new Error("stats unreachable"));
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
expect(await screen.findByText("lanes unreachable")).toBeInTheDocument();
|
||||
expect(await screen.findByText("stats unreachable")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file Tests for the skill/plugin usage panel: plugin chips carry the combined
|
||||
* skill + MCP call count, skills rank by calls, and the status filter is passed
|
||||
* through to the API so the panel matches the rest of the page.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SkillUsagePanel } from "../SkillUsagePanel";
|
||||
|
||||
const usageMock = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { workflows: { skillUsage: (s?: string) => usageMock(s) } } };
|
||||
});
|
||||
|
||||
const USAGE = {
|
||||
skills: [
|
||||
{ name: "ccam:ship-feature", plugin: "ccam", count: 12, sessions: 5 },
|
||||
{ name: "daily-report", plugin: null, count: 3, sessions: 3 },
|
||||
],
|
||||
plugins: [{ name: "ccam", skills: 1, skillCalls: 12, mcpCalls: 4 }],
|
||||
};
|
||||
|
||||
describe("SkillUsagePanel", () => {
|
||||
it("sums a plugin's skill calls and MCP calls into one chip total", async () => {
|
||||
usageMock.mockResolvedValue(USAGE);
|
||||
render(<SkillUsagePanel statusFilter="all" />);
|
||||
|
||||
expect(await screen.findByTestId("plugin-usage-ccam")).toHaveTextContent("16");
|
||||
});
|
||||
|
||||
it("lists each skill with its call and session counts", async () => {
|
||||
usageMock.mockResolvedValue(USAGE);
|
||||
render(<SkillUsagePanel statusFilter="all" />);
|
||||
|
||||
const row = await screen.findByTestId("skill-usage-ccam:ship-feature");
|
||||
expect(row).toHaveTextContent("12");
|
||||
expect(row).toHaveTextContent("5");
|
||||
});
|
||||
|
||||
it("forwards the page's status filter to the API", async () => {
|
||||
usageMock.mockResolvedValue(USAGE);
|
||||
render(<SkillUsagePanel statusFilter="active" />);
|
||||
|
||||
await screen.findByTestId("plugin-usage-ccam");
|
||||
expect(usageMock).toHaveBeenCalledWith("active");
|
||||
});
|
||||
|
||||
it("shows an empty state when nothing was invoked", async () => {
|
||||
usageMock.mockResolvedValue({ skills: [], plugins: [] });
|
||||
render(<SkillUsagePanel statusFilter="all" />);
|
||||
|
||||
expect(await screen.findByText(/No skill invocation/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user