feat(workflows): drop the patterns list and model delegation flow
Neither panel drove a decision: the detected-pattern ranking restated what the orchestration DAG already shows, and the delegation flow's main→subagent model pairing is visible in the per-session drill-in. Removes both components and their i18n blocks, promotes the error-propagation map to full width, and renumbers the remaining sections 4-7.
This commit is contained in:
@@ -1,531 +0,0 @@
|
||||
/**
|
||||
* @file ModelDelegationFlow.tsx
|
||||
* @description Defines the ModelDelegationFlow React component that visualizes the relationships between main models and subagent models in a flow diagram using D3.js. The component takes model delegation data as input and renders an SVG diagram that shows how different models are connected based on their usage in agents and sessions. It categorizes models into families (opus, sonnet, haiku, other) for color-coding and provides a clear visual representation of model delegation patterns.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
* - `../../lib/format`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ModelDelegationFlowProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ModelDelegationFlow` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ModelDelegationFlowProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ModelDelegationFlow**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
import type { ModelDelegationData } from "../../lib/types";
|
||||
import { formatModelName } from "../../lib/format";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function modelFamily(name: string): "opus" | "sonnet" | "haiku" | "other" {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("opus")) return "opus";
|
||||
if (lower.includes("sonnet")) return "sonnet";
|
||||
if (lower.includes("haiku")) return "haiku";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// Model formatting is handled by the shared formatModelName utility.
|
||||
|
||||
// ── Color palette per model family ───────────────────────────────────────────
|
||||
|
||||
const FAMILY_COLORS = {
|
||||
opus: {
|
||||
grad: ["#7c3aed", "#a855f7"] as [string, string],
|
||||
stroke: "#a855f7",
|
||||
text: "#e9d5ff",
|
||||
badge: "rgba(168,85,247,0.15)",
|
||||
},
|
||||
sonnet: {
|
||||
grad: ["#1d4ed8", "#3b82f6"] as [string, string],
|
||||
stroke: "#3b82f6",
|
||||
text: "#bfdbfe",
|
||||
badge: "rgba(59,130,246,0.15)",
|
||||
},
|
||||
haiku: {
|
||||
grad: ["#065f46", "#10b981"] as [string, string],
|
||||
stroke: "#10b981",
|
||||
text: "#a7f3d0",
|
||||
badge: "rgba(16,185,129,0.15)",
|
||||
},
|
||||
other: {
|
||||
grad: ["#374151", "#6b7280"] as [string, string],
|
||||
stroke: "#6b7280",
|
||||
text: "#d1d5db",
|
||||
badge: "rgba(107,114,128,0.15)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── Types used internally ─────────────────────────────────────────────────────
|
||||
|
||||
interface NodeDatum {
|
||||
id: string;
|
||||
label: string;
|
||||
family: "opus" | "sonnet" | "haiku" | "other";
|
||||
agentCount: number;
|
||||
sessionCount: number;
|
||||
totalTokens: number;
|
||||
side: "main" | "sub";
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface EdgeDatum {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
type ShowTipFn = (node: NodeDatum, anchor: SVGGraphicsElement) => void;
|
||||
type HideTipFn = () => void;
|
||||
|
||||
// ── D3 chart renderer ─────────────────────────────────────────────────────────
|
||||
|
||||
const NODE_W = 160;
|
||||
const NODE_H = 80;
|
||||
const NODE_RX = 10;
|
||||
const COL_GAP = 200;
|
||||
const ROW_GAP = 108;
|
||||
const PADDING = { top: 40, left: 24, right: 24, bottom: 24 };
|
||||
|
||||
function renderFlow(
|
||||
svg: SVGSVGElement,
|
||||
mainNodes: NodeDatum[],
|
||||
subNodes: NodeDatum[],
|
||||
edges: EdgeDatum[],
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
showTip: ShowTipFn,
|
||||
hideTip: HideTipFn
|
||||
): void {
|
||||
const allNodes = [...mainNodes, ...subNodes];
|
||||
|
||||
const totalRows = Math.max(mainNodes.length, subNodes.length);
|
||||
const chartH = totalRows * ROW_GAP + PADDING.top + PADDING.bottom;
|
||||
const chartW = NODE_W * 2 + COL_GAP + PADDING.left + PADDING.right;
|
||||
|
||||
const root = d3.select(svg);
|
||||
root.selectAll("*").remove();
|
||||
root.attr("viewBox", `0 0 ${chartW} ${chartH}`).attr("preserveAspectRatio", "xMidYMid meet");
|
||||
|
||||
const defs = root.append("defs");
|
||||
|
||||
// Gradient defs per family
|
||||
(["opus", "sonnet", "haiku", "other"] as const).forEach((fam) => {
|
||||
const colors = FAMILY_COLORS[fam];
|
||||
const grad = defs
|
||||
.append("linearGradient")
|
||||
.attr("id", `flow-grad-${fam}`)
|
||||
.attr("x1", "0%")
|
||||
.attr("y1", "0%")
|
||||
.attr("x2", "100%")
|
||||
.attr("y2", "100%");
|
||||
grad.append("stop").attr("offset", "0%").attr("stop-color", colors.grad[0]);
|
||||
grad.append("stop").attr("offset", "100%").attr("stop-color", colors.grad[1]);
|
||||
});
|
||||
|
||||
const g = root.append("g");
|
||||
|
||||
// Column labels
|
||||
const labelY = PADDING.top - 16;
|
||||
g.append("text")
|
||||
.attr("x", PADDING.left + NODE_W / 2)
|
||||
.attr("y", labelY)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 11)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("letter-spacing", "0.08em")
|
||||
.text(t("modelDelegation.mainModels"));
|
||||
|
||||
g.append("text")
|
||||
.attr("x", PADDING.left + NODE_W + COL_GAP + NODE_W / 2)
|
||||
.attr("y", labelY)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 11)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("letter-spacing", "0.08em")
|
||||
.text(t("modelDelegation.subagentModels"));
|
||||
|
||||
// Build lookup for node positions
|
||||
const nodeMap = new Map<string, NodeDatum>(allNodes.map((n) => [n.id, n]));
|
||||
|
||||
// Draw edges (cubic bezier curves)
|
||||
edges.forEach(({ sourceId, targetId }) => {
|
||||
const src = nodeMap.get(sourceId);
|
||||
const tgt = nodeMap.get(targetId);
|
||||
if (!src || !tgt) return;
|
||||
|
||||
const x1 = src.x + NODE_W;
|
||||
const y1 = src.y + NODE_H / 2;
|
||||
const x2 = tgt.x;
|
||||
const y2 = tgt.y + NODE_H / 2;
|
||||
const cx = (x1 + x2) / 2;
|
||||
|
||||
g.append("path")
|
||||
.attr("d", `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#2a2a3d")
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("opacity", 0.7);
|
||||
});
|
||||
|
||||
// Draw nodes
|
||||
allNodes.forEach((node) => {
|
||||
const colors = FAMILY_COLORS[node.family];
|
||||
const ng = g
|
||||
.append("g")
|
||||
.attr("transform", `translate(${node.x},${node.y})`)
|
||||
.style("cursor", "default")
|
||||
.on("mouseenter", function () {
|
||||
showTip(node, this as SVGGraphicsElement);
|
||||
})
|
||||
.on("mouseleave", () => hideTip());
|
||||
|
||||
// Border glow rect (slightly larger)
|
||||
ng.append("rect")
|
||||
.attr("x", -1)
|
||||
.attr("y", -1)
|
||||
.attr("width", NODE_W + 2)
|
||||
.attr("height", NODE_H + 2)
|
||||
.attr("rx", NODE_RX + 1)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", colors.stroke)
|
||||
.attr("stroke-width", 1)
|
||||
.attr("opacity", 0.25);
|
||||
|
||||
// Main rect with gradient
|
||||
ng.append("rect")
|
||||
.attr("width", NODE_W)
|
||||
.attr("height", NODE_H)
|
||||
.attr("rx", NODE_RX)
|
||||
.attr("fill", `url(#flow-grad-${node.family})`)
|
||||
.attr("fill-opacity", 0.18)
|
||||
.attr("stroke", colors.stroke)
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Model name
|
||||
ng.append("text")
|
||||
.attr("x", 12)
|
||||
.attr("y", 22)
|
||||
.attr("fill", colors.text)
|
||||
.attr("font-size", 11)
|
||||
.attr("font-weight", "600")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(formatModelName(node.label) ?? node.label);
|
||||
|
||||
// Agent count pill
|
||||
ng.append("rect")
|
||||
.attr("x", 10)
|
||||
.attr("y", 32)
|
||||
.attr("width", 64)
|
||||
.attr("height", 16)
|
||||
.attr("rx", 4)
|
||||
.attr("fill", colors.badge);
|
||||
|
||||
ng.append("text")
|
||||
.attr("x", 42)
|
||||
.attr("y", 43.5)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", colors.text)
|
||||
.attr("font-size", 9.5)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(t("modelDelegation.agentsCount", { count: node.agentCount }));
|
||||
|
||||
// Token count
|
||||
if (node.totalTokens > 0) {
|
||||
ng.append("text")
|
||||
.attr("x", 12)
|
||||
.attr("y", 66)
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 9.5)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(t("modelDelegation.tokensCount", { tokens: fmtTokens(node.totalTokens) }));
|
||||
}
|
||||
|
||||
// Session count (main nodes only)
|
||||
if (node.side === "main" && node.sessionCount > 0) {
|
||||
ng.append("text")
|
||||
.attr("x", NODE_W - 10)
|
||||
.attr("y", 66)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 9.5)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(t("modelDelegation.sessionsCount", { count: node.sessionCount }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ModelDelegationFlowProps {
|
||||
data: ModelDelegationData;
|
||||
}
|
||||
|
||||
export function ModelDelegationFlow({ data }: ModelDelegationFlowProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasData = data.mainModels.length > 0 || data.subagentModels.length > 0;
|
||||
const totalAgents = countTotalAgents(data);
|
||||
|
||||
const hideTip = useCallback(() => {
|
||||
const tip = tipRef.current;
|
||||
if (tip) tip.style.opacity = "0";
|
||||
}, []);
|
||||
|
||||
const showTip = useCallback(
|
||||
(node: NodeDatum, anchor: SVGGraphicsElement) => {
|
||||
const tip = tipRef.current;
|
||||
if (!tip) return;
|
||||
buildModelDelegationTooltip(tip, node, totalAgents, t);
|
||||
const r = anchor.getBoundingClientRect();
|
||||
tip.style.opacity = "0";
|
||||
tip.style.display = "block";
|
||||
const tipW = tip.offsetWidth || 280;
|
||||
const tipH = tip.offsetHeight || 160;
|
||||
|
||||
const margin = 8;
|
||||
let left = r.left + r.width / 2 - tipW / 2;
|
||||
if (left < margin) left = margin;
|
||||
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
|
||||
let top = r.top - tipH - 10;
|
||||
if (top < margin) top = r.bottom + 10;
|
||||
|
||||
tip.style.left = `${left}px`;
|
||||
tip.style.top = `${top}px`;
|
||||
tip.style.opacity = "1";
|
||||
},
|
||||
[totalAgents, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !hasData) return;
|
||||
|
||||
const tokenMap = new Map<string, number>();
|
||||
data.tokensByModel.forEach(({ model, input_tokens, output_tokens }) => {
|
||||
tokenMap.set(model, (tokenMap.get(model) ?? 0) + input_tokens + output_tokens);
|
||||
});
|
||||
|
||||
const mainNodes: NodeDatum[] = data.mainModels.map((m, i) => ({
|
||||
id: `main-${m.model}`,
|
||||
label: formatModelName(m.model) ?? m.model,
|
||||
family: modelFamily(m.model),
|
||||
agentCount: m.agent_count,
|
||||
sessionCount: m.session_count,
|
||||
totalTokens: tokenMap.get(m.model) ?? 0,
|
||||
side: "main",
|
||||
x: PADDING.left,
|
||||
y: PADDING.top + i * ROW_GAP,
|
||||
}));
|
||||
|
||||
const subNodes: NodeDatum[] = data.subagentModels.map((m, i) => ({
|
||||
id: `sub-${m.model}`,
|
||||
label: formatModelName(m.model) ?? m.model,
|
||||
family: modelFamily(m.model),
|
||||
agentCount: m.agent_count,
|
||||
sessionCount: 0,
|
||||
totalTokens: tokenMap.get(m.model) ?? 0,
|
||||
side: "sub",
|
||||
x: PADDING.left + NODE_W + COL_GAP,
|
||||
y: PADDING.top + i * ROW_GAP,
|
||||
}));
|
||||
|
||||
// Connect all main models to all subagent models that share a family, or all if no match
|
||||
const edges: EdgeDatum[] = [];
|
||||
mainNodes.forEach((mn) => {
|
||||
subNodes.forEach((sn) => {
|
||||
edges.push({ sourceId: mn.id, targetId: sn.id });
|
||||
});
|
||||
});
|
||||
|
||||
renderFlow(svgRef.current, mainNodes, subNodes, edges, t, showTip, hideTip);
|
||||
hideTip();
|
||||
}, [data, hasData, t, showTip, hideTip]);
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-fg-muted">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
<span className="text-sm">{t("modelDelegation.noData")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full overflow-x-auto relative" onMouseLeave={hideTip}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="w-full"
|
||||
style={{ minHeight: 120 }}
|
||||
aria-label={t("modelDelegation.ariaLabel")}
|
||||
role="img"
|
||||
/>
|
||||
<div
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
aria-hidden="true"
|
||||
className="fixed z-50 px-3 py-2 rounded-lg shadow-2xl pointer-events-none"
|
||||
style={{
|
||||
display: "none",
|
||||
opacity: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
background: "#12121f",
|
||||
border: "1px solid #2a2a4a",
|
||||
color: "#e2e8f0",
|
||||
minWidth: 240,
|
||||
maxWidth: 320,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tooltip helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function countTotalAgents(data: ModelDelegationData): number {
|
||||
const mainSum = data.mainModels.reduce((s, m) => s + m.agent_count, 0);
|
||||
const subSum = data.subagentModels.reduce((s, m) => s + m.agent_count, 0);
|
||||
return mainSum + subSum;
|
||||
}
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function describeFamily(family: NodeDatum["family"], t: TFn): string {
|
||||
switch (family) {
|
||||
case "opus":
|
||||
return t("modelDelegation.tooltip.family.opus");
|
||||
case "sonnet":
|
||||
return t("modelDelegation.tooltip.family.sonnet");
|
||||
case "haiku":
|
||||
return t("modelDelegation.tooltip.family.haiku");
|
||||
case "other":
|
||||
return t("modelDelegation.tooltip.family.other");
|
||||
}
|
||||
}
|
||||
|
||||
function buildModelDelegationTooltip(
|
||||
el: HTMLDivElement,
|
||||
node: NodeDatum,
|
||||
totalAgents: number,
|
||||
t: TFn
|
||||
) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
|
||||
const sharePct = totalAgents > 0 ? `${((node.agentCount / totalAgents) * 100).toFixed(1)}%` : "-";
|
||||
const sideLabel =
|
||||
node.side === "main"
|
||||
? t("modelDelegation.tooltip.mainModel")
|
||||
: t("modelDelegation.tooltip.subagentModel");
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0";
|
||||
title.textContent = node.label;
|
||||
el.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.style.cssText =
|
||||
"font-size:10px;color:#64748b;margin:2px 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
||||
subtitle.textContent = `${sideLabel} · ${node.family}`;
|
||||
el.appendChild(subtitle);
|
||||
|
||||
const addRow = (label: string, value: string) => {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText =
|
||||
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.style.color = "#64748b";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.textContent = value;
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(val);
|
||||
el.appendChild(row);
|
||||
};
|
||||
|
||||
addRow(t("modelDelegation.tooltip.agentRuns"), node.agentCount.toLocaleString());
|
||||
addRow(t("modelDelegation.tooltip.shareOfAll"), sharePct);
|
||||
if (node.side === "main") {
|
||||
addRow(t("modelDelegation.tooltip.sessionsOnModel"), String(node.sessionCount));
|
||||
}
|
||||
addRow(t("modelDelegation.tooltip.totalTokens"), node.totalTokens.toLocaleString());
|
||||
|
||||
const desc = document.createElement("p");
|
||||
desc.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:8px 0 0";
|
||||
desc.textContent = describeFamily(node.family, t);
|
||||
el.appendChild(desc);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.style.cssText = "font-size:11px;color:#64748b;line-height:1.45;margin:6px 0 0";
|
||||
hint.textContent =
|
||||
node.side === "main"
|
||||
? t("modelDelegation.tooltip.lines.main")
|
||||
: t("modelDelegation.tooltip.lines.sub");
|
||||
el.appendChild(hint);
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
/**
|
||||
* @file 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.
|
||||
*
|
||||
* ## 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user