feat(workflows): show lane pipelines, drop the deep analytics charts
The Workflows page rendered eleven charts of Claude Code harness internals (collaboration network, subagent effectiveness, patterns, model delegation, error propagation, concurrency, complexity, compaction). Too specialized to read and nothing to act on, so they are gone along with their i18n keys. What is left is what a reader can act on: headline stats, Workflow-tool runs, the spawning DAG, the tool flow and the per-session drill-in — plus a new first section for this dashboard's OWN pipelines. LanePipelinePanel renders a horizontally scrolling lane strip and the selected lane's PipelineMap, reusing the Workspace components so a stage looks identical on both pages. The panel is read-only: the console-never-writes-a-stage boundary applies here too, so every lane action stays on the Workspace page. The workflows API response shape is untouched. Closes #6
This commit is contained in:
+10
-24
@@ -455,7 +455,7 @@ graph TD
|
||||
DETAIL --> CONV["ConversationView<br/>(MarkdownContent + CodeBlock<br/>+ ToolCallBlock per-tool styling)"]
|
||||
DETAIL --> TL["Event Timeline"]
|
||||
ACTIVITY --> FEED["Streaming Event List<br/>(click row → expand payload;<br/>Session btn → session detail)"]
|
||||
WORKFLOWS_P --> WFC["12 D3.js components<br/>(workflows/ directory)"]
|
||||
WORKFLOWS_P --> WFC["Lane pipelines +<br/>4 analytics components<br/>(workflows/ directory)"]
|
||||
|
||||
style APP fill:#6366f1,stroke:#818cf8,color:#fff
|
||||
style LAYOUT fill:#1a1a28,stroke:#2a2a3d,color:#e4e4ed
|
||||
@@ -465,7 +465,6 @@ graph TD
|
||||
|
||||
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
|
||||
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
|
||||
- **`workflows/CompactionImpact.tsx`** — redesigned from a one-bar-per-session chart into a "sessions by compaction count" histogram (D3) with axis titles, stat tiles (total / sessions affected / avg / peak), an explanatory help line, a plain-English summary, and rich React-managed hover tooltips (full-height per-bucket hit-area + bar highlight) matching the other charts.
|
||||
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
|
||||
|
||||
### Self-hosted assets (no external CDN)
|
||||
@@ -657,33 +656,21 @@ graph TD
|
||||
WS_D --> WF
|
||||
|
||||
WF --> S1["WorkflowStats<br/>Summary cards"]
|
||||
WF --> S2["OrchestrationDAG<br/>Horizontal DAG —<br/>Sessions → Main → Subagents → Outcomes"]
|
||||
WF --> S3["ToolExecutionFlow<br/>d3-sankey tool transitions"]
|
||||
WF --> S4["AgentCollaborationNetwork<br/>Force-directed pipeline graph"]
|
||||
WF --> S5["SubagentEffectiveness<br/>SVG success rings +<br/>day-of-week sparklines"]
|
||||
WF --> S6["WorkflowPatterns<br/>Auto-detected sequences"]
|
||||
WF --> S7["ModelDelegationFlow<br/>Model → agent routing"]
|
||||
WF --> S8["ErrorPropagationMap<br/>React horizontal bars +<br/>API/session error support"]
|
||||
WF --> S9["ConcurrencyTimeline<br/>Swim-lane parallel execution"]
|
||||
WF --> S10["SessionComplexityScatter<br/>D3 bubble chart"]
|
||||
WF --> S11["CompactionImpact<br/>Token compression analysis"]
|
||||
WF --> S12["SessionDrillIn<br/>Searchable session explorer<br/>(3 tabs: tree / timeline / events)"]
|
||||
WF --> S2["LanePipelinePanel<br/>Lane strip + selected lane's<br/>PipelineMap (read-only)"]
|
||||
WF --> S3["WorkflowRunsPanel<br/>Workflow-tool runs<br/>(on-disk journals)"]
|
||||
WF --> S4["OrchestrationDAG<br/>Horizontal DAG —<br/>Sessions → Main → Subagents → Outcomes"]
|
||||
WF --> S5["ToolExecutionFlow<br/>d3-sankey tool transitions"]
|
||||
WF --> S6["SessionDrillIn<br/>Searchable session explorer<br/>(3 tabs: tree / timeline / events)"]
|
||||
|
||||
classDef root fill:#6366f1,stroke:#818cf8,color:#fff
|
||||
```
|
||||
|
||||
| Component | Visualization | D3 Feature |
|
||||
| --- | --- | --- |
|
||||
| `LanePipelinePanel` | This dashboard's own lanes: a horizontally scrolling lane strip and the selected lane's pipeline map | Plain React; reuses `lanes/LaneStripCard` + `lanes/PipelineMap`, polls `GET /api/lanes` and debounces websocket events. Read-only — every lane action stays on the Workspace page |
|
||||
| `WorkflowRunsPanel` | Workflow-tool runs reconstructed from on-disk journals | Per-run phase chips, per-agent table, expandable results |
|
||||
| `OrchestrationDAG` | Horizontal DAG of aggregate spawning patterns | Custom DAG layout, capped at top 7 subagent types with overflow node |
|
||||
| `ToolExecutionFlow` | Tool-to-tool transition Sankey diagram | `d3-sankey` |
|
||||
| `AgentCollaborationNetwork` | Agent pipeline graph with directed edges | `d3-force` with arrowheads and frequency labels |
|
||||
| `SubagentEffectiveness` | Scorecard grid with success rate rings | SVG arc rendering, day-of-week sparklines (Mon-Sun). Per-bar tooltip is rendered through `createPortal` to `document.body` and positioned with viewport-clamped fixed coordinates so it escapes the card's `overflow:hidden` (and any `hover:translate` containing block) and is never clipped by the card edge — fixes Sun/Sat/Mon/Fri visibility |
|
||||
| `WorkflowPatterns` | Common orchestration sequences | Pattern detection from event data; clicking a row expands an inline detail panel with the full step chain, a stats grid, a deterministic narrative (shape buckets: solo / two-step / short / long; loop detection; frequency bucket: dominant > 50% / common > 25% / regular > 10% / niche), and a practical suggestion bucket. All copy is i18n-driven (`workflows.patterns.detail.*`) |
|
||||
| `ModelDelegationFlow` | Model routing through agent hierarchies | Hierarchical layout |
|
||||
| `ErrorPropagationMap` | Error clustering by hierarchy depth with API/session event errors | Pure React horizontal bars (replaced D3 bar chart), `eventErrors` support for API and session-level errors |
|
||||
| `ConcurrencyTimeline` | Swim-lane parallel agent execution | Time-scaled horizontal bars |
|
||||
| `SessionComplexityScatter` | Duration vs agents vs tokens | D3 bubble/scatter chart |
|
||||
| `CompactionImpact` | Token compression events and recovery | Before/after comparison |
|
||||
| `SessionDrillIn` | Per-session agent tree, tool timeline, events | Searchable dropdown with pagination, 3 tabs |
|
||||
|
||||
**Cross-filtering:** Clicking nodes in the OrchestrationDAG filters data in other sections. **JSON export:** All workflow data can be exported as JSON from the page header.
|
||||
@@ -697,7 +684,6 @@ Every chart in the Workflows page follows a single, deterministic tooltip patter
|
||||
- **Container-level `mouseleave` fallback.** The chart's outer wrapper also calls `hideTip()` on leave. If a node-level handler is missed because D3 destroyed the element under the cursor on data refresh, the wrapper guarantees dismissal.
|
||||
- **Re-render safety.** Each chart's render effect ends with `hideTip()` so any stale tooltip from before a websocket-driven refresh is cleared the moment new data arrives.
|
||||
- **Fade transitions.** Tooltips stay in the DOM with `opacity: 0` and `pointer-events: none`, transitioning over 120 ms — show/hide feels smooth instead of flickering, and the element never intercepts pointer events that would prevent `mouseleave` from firing on the chart.
|
||||
- **Portal escape for clipped containers.** `SubagentEffectiveness` cards use `overflow:hidden` plus a hover `translate` (which becomes the fixed-position containing block), so its sparkline tooltip is rendered with `react-dom.createPortal(…, document.body)` rather than as a child of the card. Coordinates are computed from the bar's bounding rect and clamped to the viewport, so the tooltip is visible on every day of the week regardless of the card's screen position.
|
||||
|
||||
### Structured info popovers
|
||||
|
||||
@@ -726,7 +712,7 @@ flowchart LR
|
||||
|
||||
See [docs/I18N.md](docs/I18N.md) for resource strategy, key naming conventions, localization tests, troubleshooting, and rollout checklists.
|
||||
|
||||
**Coverage scope.** The translation layer extends end-to-end through the Workflows tooltip surfaces — `workflows.stats.tooltip.*` (calculation copy, deterministic value-bucket interpretations, metric phrases), `workflows.chartInfo.*` (per-chart "What / How to read / Why" entries for all 11 sections), `workflows.{orchestration,toolFlow,pipeline,modelDelegation,concurrency}.tooltip.*` (per-graph hover content), and `workflows.patterns.detail.*` (Workflow Patterns expansion narrative + suggestion buckets) — plus the Settings additions: `settings.pricing.tooltip.*` (pricing rule lookup, `%` wildcard syntax, manual-update reminder), `settings.claudeHome.*` (CLAUDE_HOME panel labels), and the full `settings.import.*` block (now translated to vi/zh, where the panel previously fell back to English).
|
||||
**Coverage scope.** The translation layer extends end-to-end through the Workflows tooltip surfaces — `workflows.stats.tooltip.*` (calculation copy, deterministic value-bucket interpretations, metric phrases), `workflows.chartInfo.*` (per-section "What / How to read / Why" entries), and `workflows.{orchestration,toolFlow}.tooltip.*` (per-graph hover content) — plus the Settings additions: `settings.pricing.tooltip.*` (pricing rule lookup, `%` wildcard syntax, manual-update reminder), `settings.claudeHome.*` (CLAUDE_HOME panel labels), and the full `settings.import.*` block (now translated to vi/zh, where the panel previously fell back to English).
|
||||
|
||||
---
|
||||
|
||||
@@ -2484,7 +2470,7 @@ The function handles:
|
||||
- Version number dot-joining (hyphen-separated digits → dotted)
|
||||
- Title-casing for word segments
|
||||
|
||||
Components that consume this: SessionDetail, Analytics (donut chart + breakdown), Dashboard (model stats), MessageList, SessionCard, AgentCard, SessionComplexityScatter, SessionDrillIn, ModelDelegationFlow, and EventDetail.
|
||||
Components that consume this: SessionDetail, Analytics (donut chart + breakdown), Dashboard (model stats), MessageList, SessionCard, AgentCard, SessionDrillIn, and EventDetail.
|
||||
|
||||
### Data Flow
|
||||
|
||||
|
||||
+2
-2
@@ -178,7 +178,7 @@ client/
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
||||
│ │ └── workflows/ # D3.js workflow visualization components (12 files)
|
||||
│ │ └── workflows/ # Workflows page panels: lane pipelines + D3 analytics (6 files)
|
||||
│ │
|
||||
│ ├── pages/ # Route pages
|
||||
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs
|
||||
@@ -230,7 +230,7 @@ graph TB
|
||||
Sessions[Sessions<br/>filterable table]
|
||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||
Feed[ActivityFeed<br/>streaming event log]
|
||||
Workflows[Workflows<br/>D3.js visualizations]
|
||||
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
|
||||
Settings[Settings<br/>pricing + notifications + hooks]
|
||||
|
||||
App --> Layout
|
||||
|
||||
@@ -1,638 +0,0 @@
|
||||
/**
|
||||
* @file AgentCollaborationNetwork.tsx
|
||||
* @description Defines the AgentCollaborationNetwork React component that visualizes the collaboration between different agent types in a directed graph format using D3.js. The component takes in effectiveness data for each agent type and the edges representing their interactions, and renders an interactive force-directed graph where nodes represent agent types and edges represent the frequency of sequential runs. The graph includes tooltips for detailed information on hover and a legend for clarity.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `AgentCollaborationNetworkProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AgentCollaborationNetwork` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **AgentCollaborationNetworkProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **AgentCollaborationNetwork**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useRef, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AgentCollaborationNetworkProps {
|
||||
effectiveness: Array<{
|
||||
subagent_type: string;
|
||||
total: number;
|
||||
completed: number;
|
||||
errors: number;
|
||||
sessions: number;
|
||||
successRate: number;
|
||||
}>;
|
||||
edges: Array<{ source: string; target: string; weight: number }>;
|
||||
}
|
||||
|
||||
interface PipelineNode extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
total: number;
|
||||
sessions: number;
|
||||
successRate: number;
|
||||
colorIndex: number;
|
||||
}
|
||||
|
||||
interface PipelineLink extends d3.SimulationLinkDatum<PipelineNode> {
|
||||
weight: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const PALETTE = [
|
||||
"#6366f1",
|
||||
"#3b82f6",
|
||||
"#22c55e",
|
||||
"#a855f7",
|
||||
"#f59e0b",
|
||||
"#ec4899",
|
||||
"#06b6d4",
|
||||
"#f97316",
|
||||
"#ef4444",
|
||||
"#14b8a6",
|
||||
];
|
||||
|
||||
const STROKE_PALETTE = [
|
||||
"#818cf8",
|
||||
"#60a5fa",
|
||||
"#4ade80",
|
||||
"#c084fc",
|
||||
"#fbbf24",
|
||||
"#f472b6",
|
||||
"#22d3ee",
|
||||
"#fb923c",
|
||||
"#f87171",
|
||||
"#2dd4bf",
|
||||
];
|
||||
|
||||
const MIN_R = 20;
|
||||
const MAX_R = 44;
|
||||
|
||||
// ── Safe tooltip DOM builder ──
|
||||
|
||||
function appendTooltipRow(parent: HTMLElement, label: string, value: string) {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display:flex;justify-content:space-between;gap:16px;font-size:11px";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.style.color = "#64748b";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.style.cssText = "color:#cbd5e1;font-weight:500";
|
||||
val.textContent = value;
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(val);
|
||||
parent.appendChild(row);
|
||||
}
|
||||
|
||||
function appendTooltipDescription(parent: HTMLElement, text: string) {
|
||||
const p = document.createElement("p");
|
||||
p.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;margin:8px 0 0;padding-top:8px;border-top:1px solid #2a2a4a";
|
||||
p.textContent = text;
|
||||
parent.appendChild(p);
|
||||
}
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function describeNodeRole(d: PipelineNode, t: TFn): string {
|
||||
const sr = Math.round(d.successRate);
|
||||
let healthKey: string;
|
||||
if (sr >= 95) healthKey = "pipeline.tooltip.health.perfect";
|
||||
else if (sr >= 80) healthKey = "pipeline.tooltip.health.healthy";
|
||||
else if (sr >= 50) healthKey = "pipeline.tooltip.health.shaky";
|
||||
else healthKey = "pipeline.tooltip.health.failing";
|
||||
|
||||
return t("pipeline.tooltip.nodeDescFmt", {
|
||||
id: d.id,
|
||||
total: d.total,
|
||||
sessions: d.sessions,
|
||||
rate: sr,
|
||||
health: t(healthKey),
|
||||
});
|
||||
}
|
||||
|
||||
function showTooltip(
|
||||
el: HTMLDivElement,
|
||||
d: PipelineNode,
|
||||
x: number,
|
||||
y: number,
|
||||
t: TFn,
|
||||
totalSpawns: number
|
||||
) {
|
||||
el.textContent = "";
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0 0 2px";
|
||||
title.textContent = d.id;
|
||||
el.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.style.cssText =
|
||||
"font-size:10px;color:#64748b;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
||||
subtitle.textContent = t("pipeline.tooltip.agentType");
|
||||
el.appendChild(subtitle);
|
||||
|
||||
const sharePct = totalSpawns > 0 ? `${((d.total / totalSpawns) * 100).toFixed(1)}%` : "-";
|
||||
|
||||
const rows: [string, string][] = [
|
||||
[t("pipeline.spawned"), String(d.total) + t("pipeline.spawns")],
|
||||
[t("pipeline.tooltip.shareOfAllSpawns"), sharePct],
|
||||
[t("pipeline.inSessions"), String(d.sessions)],
|
||||
[t("effectiveness.success"), Math.round(d.successRate) + "%"],
|
||||
];
|
||||
for (const [label, value] of rows) {
|
||||
appendTooltipRow(el, label, value);
|
||||
}
|
||||
|
||||
appendTooltipDescription(el, describeNodeRole(d, t));
|
||||
|
||||
el.style.maxWidth = "320px";
|
||||
positionTooltipAt(el, x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position a tooltip so its top-right corner sits near (x, y), but clamped to
|
||||
* the viewport so it never disappears behind the sidebar or the right edge.
|
||||
* Sets opacity to 1 to fade the tooltip in via its CSS transition.
|
||||
*/
|
||||
function positionTooltipAt(el: HTMLDivElement, x: number, y: number) {
|
||||
el.style.opacity = "0";
|
||||
const w = el.offsetWidth || 280;
|
||||
const h = el.offsetHeight || 160;
|
||||
const margin = 8;
|
||||
|
||||
// Default: place just below-right of the cursor
|
||||
let left = x + 14;
|
||||
let top = y + 14;
|
||||
|
||||
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
|
||||
if (left < margin) left = margin;
|
||||
if (top + h > window.innerHeight - margin) top = y - h - 14;
|
||||
if (top < margin) top = margin;
|
||||
|
||||
el.style.left = `${left}px`;
|
||||
el.style.top = `${top}px`;
|
||||
el.style.transform = "";
|
||||
// Trigger fade-in on the next frame for a smooth transition.
|
||||
requestAnimationFrame(() => {
|
||||
el.style.opacity = "1";
|
||||
});
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AgentCollaborationNetwork({
|
||||
effectiveness,
|
||||
edges,
|
||||
}: AgentCollaborationNetworkProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const simulationRef = useRef<d3.Simulation<PipelineNode, PipelineLink> | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Memoize so D3 effect only reruns when props change, not on every render
|
||||
const { nodes, links, isEmpty } = useMemo(() => {
|
||||
const nodeMap = new Map<string, PipelineNode>();
|
||||
effectiveness.forEach((item, i) => {
|
||||
nodeMap.set(item.subagent_type, {
|
||||
id: item.subagent_type,
|
||||
total: item.total,
|
||||
sessions: item.sessions,
|
||||
successRate: item.successRate,
|
||||
colorIndex: i % PALETTE.length,
|
||||
});
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
const links: PipelineLink[] = [];
|
||||
for (const e of edges) {
|
||||
if (e.source === e.target) continue;
|
||||
if (!nodeMap.has(e.source) || !nodeMap.has(e.target)) continue;
|
||||
const key = `${e.source}→${e.target}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
links.push({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
weight: e.weight,
|
||||
label: `${e.weight}x`,
|
||||
});
|
||||
}
|
||||
|
||||
const nodes = [...nodeMap.values()];
|
||||
return { nodes, links, isEmpty: nodes.length === 0 || links.length === 0 };
|
||||
}, [effectiveness, edges]);
|
||||
|
||||
// D3 simulation - only depends on memoized data
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!svg || !container || isEmpty) return;
|
||||
|
||||
simulationRef.current?.stop();
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = Math.max(450, Math.min(650, width * 0.6));
|
||||
|
||||
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
svg.style.width = `${width}px`;
|
||||
svg.style.height = `${height}px`;
|
||||
|
||||
const root = d3.select(svg);
|
||||
root.selectAll("*").remove();
|
||||
|
||||
// Arrow marker
|
||||
const defs = root.append("defs");
|
||||
defs
|
||||
.append("marker")
|
||||
.attr("id", "arrowhead")
|
||||
.attr("viewBox", "0 0 10 6")
|
||||
.attr("refX", 10)
|
||||
.attr("refY", 3)
|
||||
.attr("markerWidth", 8)
|
||||
.attr("markerHeight", 5)
|
||||
.attr("orient", "auto")
|
||||
.append("path")
|
||||
.attr("d", "M0,0 L10,3 L0,6 Z")
|
||||
.attr("fill", "#64748b");
|
||||
|
||||
// Clone data
|
||||
const simNodes = nodes.map((n) => ({ ...n }));
|
||||
const nodeById = new Map(simNodes.map((n) => [n.id, n]));
|
||||
const simLinks: PipelineLink[] = links.map((l) => ({
|
||||
...l,
|
||||
source: nodeById.get(l.source as string) ?? (l.source as string),
|
||||
target: nodeById.get(l.target as string) ?? (l.target as string),
|
||||
}));
|
||||
|
||||
// Scales
|
||||
const ext = d3.extent(simNodes, (n) => n.total) as [number, number];
|
||||
const rScale = d3
|
||||
.scaleSqrt()
|
||||
.domain([Math.max(1, ext[0] ?? 1), Math.max(2, ext[1] ?? 2)])
|
||||
.range([MIN_R, MAX_R])
|
||||
.clamp(true);
|
||||
|
||||
const wExt = d3.extent(simLinks, (l) => l.weight) as [number, number];
|
||||
const strokeScale = d3
|
||||
.scaleLinear()
|
||||
.domain([Math.max(1, wExt[0] ?? 1), Math.max(2, wExt[1] ?? 2)])
|
||||
.range([1.5, 5])
|
||||
.clamp(true);
|
||||
|
||||
// ── Links (paths for curves) ──
|
||||
const linkGroup = root.append("g");
|
||||
const linkEls = linkGroup
|
||||
.selectAll<SVGPathElement, PipelineLink>("path")
|
||||
.data(simLinks)
|
||||
.join("path")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#64748b")
|
||||
.attr("stroke-opacity", 0.55)
|
||||
.attr("stroke-width", (d) => Math.max(1.5, strokeScale(d.weight)))
|
||||
.attr("marker-end", "url(#arrowhead)");
|
||||
|
||||
// ── Edge labels ──
|
||||
const labelGroup = root.append("g");
|
||||
const edgeLabels = labelGroup
|
||||
.selectAll<SVGTextElement, PipelineLink>("text")
|
||||
.data(simLinks)
|
||||
.join("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#94a3b8")
|
||||
.attr("font-size", "9px")
|
||||
.attr("font-weight", "600")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("pointer-events", "none")
|
||||
.text((d) => d.label);
|
||||
|
||||
// ── Nodes ──
|
||||
const nodeGroup = root.append("g");
|
||||
const nodeEls = nodeGroup
|
||||
.selectAll<SVGGElement, PipelineNode>("g")
|
||||
.data(simNodes, (d) => d.id)
|
||||
.join("g")
|
||||
.attr("cursor", "grab");
|
||||
|
||||
nodeEls
|
||||
.append("circle")
|
||||
.attr("r", (d) => rScale(d.total))
|
||||
.attr("fill", (d) => PALETTE[d.colorIndex] ?? "#6366f1")
|
||||
.attr("fill-opacity", 0.8)
|
||||
.attr("stroke", (d) => STROKE_PALETTE[d.colorIndex] ?? "#818cf8")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
nodeEls
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dy", (d) => rScale(d.total) + 14)
|
||||
.attr("fill", "#cbd5e1")
|
||||
.attr("font-size", "10px")
|
||||
.attr("font-weight", "500")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("pointer-events", "none")
|
||||
.text((d) => (d.id.length > 16 ? d.id.slice(0, 14) + "\u2026" : d.id));
|
||||
|
||||
// ── Invisible wider hit areas for edge hover ──
|
||||
const hitGroup = root.append("g");
|
||||
const linkHits = hitGroup
|
||||
.selectAll<SVGPathElement, PipelineLink>("path")
|
||||
.data(simLinks)
|
||||
.join("path")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "transparent")
|
||||
.attr("stroke-width", 16)
|
||||
.attr("cursor", "pointer");
|
||||
|
||||
// ── Hover - pure DOM, zero React re-renders ──
|
||||
const tipEl = tooltipRef.current;
|
||||
|
||||
// Edge hover
|
||||
const totalSpawns = simNodes.reduce((s, n) => s + n.total, 0);
|
||||
|
||||
linkHits
|
||||
.on("mouseenter", (event: MouseEvent, d: PipelineLink) => {
|
||||
const src = d.source as PipelineNode;
|
||||
const tgt = d.target as PipelineNode;
|
||||
// Highlight this edge
|
||||
linkEls
|
||||
.attr("stroke-opacity", (l) => (l === d ? 1 : 0.1))
|
||||
.attr("stroke-width", (l) =>
|
||||
l === d ? Math.max(3, strokeScale(l.weight) + 1) : Math.max(1.5, strokeScale(l.weight))
|
||||
);
|
||||
edgeLabels.attr("fill-opacity", (l) => (l === d ? 1 : 0.15));
|
||||
|
||||
if (tipEl) {
|
||||
tipEl.textContent = "";
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0 0 2px";
|
||||
title.textContent = `${src.id} \u2192 ${tgt.id}`;
|
||||
tipEl.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.style.cssText =
|
||||
"font-size:10px;color:#64748b;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
||||
subtitle.textContent = t("pipeline.tooltip.edge");
|
||||
tipEl.appendChild(subtitle);
|
||||
|
||||
const shareOfSrc = src.total > 0 ? `${((d.weight / src.total) * 100).toFixed(1)}%` : "-";
|
||||
const shareOfTgt = tgt.total > 0 ? `${((d.weight / tgt.total) * 100).toFixed(1)}%` : "-";
|
||||
|
||||
const rows: [string, string][] = [
|
||||
[t("pipeline.tooltip.sequentialPairs"), `${d.weight}\u00d7`],
|
||||
[t("pipeline.tooltip.shareOfSrcFmt", { source: src.id }), shareOfSrc],
|
||||
[t("pipeline.tooltip.shareOfTgtFmt", { target: tgt.id }), shareOfTgt],
|
||||
[t("pipeline.tooltip.totalSpawnsFmt", { id: src.id }), String(src.total)],
|
||||
[t("pipeline.tooltip.totalSpawnsFmt", { id: tgt.id }), String(tgt.total)],
|
||||
];
|
||||
for (const [label, value] of rows) {
|
||||
appendTooltipRow(tipEl, label, value);
|
||||
}
|
||||
|
||||
appendTooltipDescription(
|
||||
tipEl,
|
||||
t("pipeline.tooltip.edgeDescFmt", {
|
||||
source: src.id,
|
||||
target: tgt.id,
|
||||
count: d.weight,
|
||||
})
|
||||
);
|
||||
|
||||
tipEl.style.maxWidth = "320px";
|
||||
positionTooltipAt(tipEl, event.clientX, event.clientY);
|
||||
}
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
linkEls
|
||||
.attr("stroke-opacity", 0.55)
|
||||
.attr("stroke-width", (d) => Math.max(1.5, strokeScale(d.weight)));
|
||||
edgeLabels.attr("fill-opacity", 1);
|
||||
if (tipEl) tipEl.style.opacity = "0";
|
||||
});
|
||||
|
||||
// Node hover
|
||||
nodeEls
|
||||
.on("mouseenter", (event: MouseEvent, d: PipelineNode) => {
|
||||
linkEls.attr("stroke-opacity", (l) => {
|
||||
const s = (l.source as PipelineNode).id;
|
||||
const t = (l.target as PipelineNode).id;
|
||||
return s === d.id || t === d.id ? 0.9 : 0.08;
|
||||
});
|
||||
edgeLabels.attr("fill-opacity", (l) => {
|
||||
const s = (l.source as PipelineNode).id;
|
||||
const t = (l.target as PipelineNode).id;
|
||||
return s === d.id || t === d.id ? 1 : 0.15;
|
||||
});
|
||||
d3.select(event.currentTarget as SVGGElement)
|
||||
.select("circle")
|
||||
.attr("stroke-width", 4);
|
||||
if (tipEl) showTooltip(tipEl, d, event.clientX, event.clientY, t, totalSpawns);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
linkEls.attr("stroke-opacity", 0.55);
|
||||
edgeLabels.attr("fill-opacity", 1);
|
||||
nodeEls.selectAll("circle").attr("stroke-width", 2);
|
||||
if (tipEl) tipEl.style.opacity = "0";
|
||||
});
|
||||
|
||||
// ── Drag ──
|
||||
const drag = d3
|
||||
.drag<SVGGElement, PipelineNode>()
|
||||
.on("start", (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0.12).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
})
|
||||
.on("drag", (event, d) => {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on("end", (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
});
|
||||
nodeEls.call(drag);
|
||||
|
||||
// ── Simulation ──
|
||||
const simulation = d3
|
||||
.forceSimulation<PipelineNode>(simNodes)
|
||||
.force(
|
||||
"link",
|
||||
d3
|
||||
.forceLink<PipelineNode, PipelineLink>(simLinks)
|
||||
.id((d) => d.id)
|
||||
.distance(250)
|
||||
)
|
||||
.force("charge", d3.forceManyBody<PipelineNode>().strength(-800))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force(
|
||||
"collision",
|
||||
d3.forceCollide<PipelineNode>().radius((d) => rScale(d.total) + 30)
|
||||
)
|
||||
.force("x", d3.forceX(width / 2).strength(0.03))
|
||||
.force("y", d3.forceY(height / 2).strength(0.03))
|
||||
.alpha(0.5)
|
||||
.on("tick", () => {
|
||||
// Clamp nodes inside viewBox
|
||||
for (const n of simNodes) {
|
||||
const r = rScale(n.total) + 16;
|
||||
n.x = Math.max(r, Math.min(width - r, n.x ?? width / 2));
|
||||
n.y = Math.max(r, Math.min(height - r, n.y ?? height / 2));
|
||||
}
|
||||
|
||||
// Build path for each edge (shared by visible + hit area)
|
||||
const pathFor = (d: PipelineLink) => {
|
||||
const s = d.source as PipelineNode;
|
||||
const t = d.target as PipelineNode;
|
||||
const sx = s.x ?? 0,
|
||||
sy = s.y ?? 0;
|
||||
const tx = t.x ?? 0,
|
||||
ty = t.y ?? 0;
|
||||
const dx = tx - sx,
|
||||
dy = ty - sy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const sr = rScale(s.total);
|
||||
const tr = rScale(t.total) + 8;
|
||||
const x1 = sx + (dx / dist) * sr;
|
||||
const y1 = sy + (dy / dist) * sr;
|
||||
const x2 = tx - (dx / dist) * tr;
|
||||
const y2 = ty - (dy / dist) * tr;
|
||||
const mx = (x1 + x2) / 2 - dy * 0.1;
|
||||
const my = (y1 + y2) / 2 + dx * 0.1;
|
||||
return `M${x1},${y1} Q${mx},${my} ${x2},${y2}`;
|
||||
};
|
||||
|
||||
linkEls.attr("d", pathFor);
|
||||
linkHits.attr("d", pathFor);
|
||||
|
||||
edgeLabels.each(function (d) {
|
||||
const s = d.source as PipelineNode;
|
||||
const t = d.target as PipelineNode;
|
||||
const sx = s.x ?? 0,
|
||||
sy = s.y ?? 0,
|
||||
tx = t.x ?? 0,
|
||||
ty = t.y ?? 0;
|
||||
const ddx = tx - sx,
|
||||
ddy = ty - sy;
|
||||
d3.select(this)
|
||||
.attr("x", (sx + tx) / 2 - ddy * 0.1)
|
||||
.attr("y", (sy + ty) / 2 + ddx * 0.1 - 4);
|
||||
});
|
||||
|
||||
nodeEls.attr("transform", (d) => `translate(${d.x ?? 0},${d.y ?? 0})`);
|
||||
});
|
||||
|
||||
simulationRef.current = simulation as d3.Simulation<PipelineNode, PipelineLink>;
|
||||
return () => {
|
||||
simulation.stop();
|
||||
};
|
||||
}, [nodes, links, isEmpty, t]);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<p className="text-sm font-medium text-fg-secondary">{t("pipeline.noData")}</p>
|
||||
<p className="text-xs text-fg-muted mt-1">{t("pipeline.noDataDesc")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleContainerLeave = () => {
|
||||
const tip = tooltipRef.current;
|
||||
if (tip) tip.style.opacity = "0";
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full relative" onMouseLeave={handleContainerLeave}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
style={{ display: "block", width: "100%", background: "transparent" }}
|
||||
aria-label={t("pipeline.ariaLabel")}
|
||||
role="img"
|
||||
onMouseLeave={handleContainerLeave}
|
||||
/>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
aria-hidden="true"
|
||||
className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none"
|
||||
style={{
|
||||
opacity: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
minWidth: 172,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-3 px-1">
|
||||
<span className="text-[10px] text-fg-muted uppercase tracking-widest font-medium">
|
||||
{t("pipeline.legend")}
|
||||
</span>
|
||||
{nodes.map((n) => (
|
||||
<div key={n.id} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: PALETTE[n.colorIndex] ?? PALETTE[0],
|
||||
border: `1.5px solid ${STROKE_PALETTE[n.colorIndex] ?? STROKE_PALETTE[0]}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[11px] text-fg-muted">{n.id}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<svg width="20" height="8" className="flex-shrink-0">
|
||||
<line x1="0" y1="4" x2="14" y2="4" stroke="#64748b" strokeWidth="1.5" />
|
||||
<polygon points="14,1 20,4 14,7" fill="#64748b" />
|
||||
</svg>
|
||||
<span className="text-[11px] text-fg-muted">{t("pipeline.legendDesc")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
/**
|
||||
* @file CompactionImpact.tsx
|
||||
* @description Visualizes how context compaction is spread across sessions.
|
||||
* Compaction is when Claude Code compresses older conversation history into a
|
||||
* summary once a session's context window fills up. This panel surfaces the
|
||||
* at-a-glance stats (total events, sessions affected, average and peak per
|
||||
* session) and a histogram answering "how many sessions compacted N times?"
|
||||
* so the distribution is legible regardless of how many sessions exist.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `CompactionImpactProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `CompactionImpact` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **CompactionImpactProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **CompactionImpact**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
import type { CompactionImpactData } from "../../lib/types";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Roll the per-session list into a histogram: for each compaction count k
|
||||
* (1..peak), how many sessions compacted exactly k times. */
|
||||
function toHistogram(perSession: CompactionImpactData["perSession"]): Array<{
|
||||
count: number;
|
||||
sessions: number;
|
||||
}> {
|
||||
const peak = perSession.reduce((m, s) => Math.max(m, s.compactions), 0);
|
||||
const buckets: Array<{ count: number; sessions: number }> = [];
|
||||
for (let k = 1; k <= peak; k++) {
|
||||
buckets.push({ count: k, sessions: perSession.filter((s) => s.compactions === k).length });
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
// ── Chart constants ───────────────────────────────────────────────────────────
|
||||
|
||||
const MARGIN = { top: 18, right: 16, bottom: 46, left: 48 };
|
||||
const CHART_HEIGHT = 200;
|
||||
|
||||
// ── D3 renderer ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface HistogramBucket {
|
||||
count: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
interface HistogramOpts {
|
||||
x: string;
|
||||
y: string;
|
||||
onHover: (e: MouseEvent, d: HistogramBucket) => void;
|
||||
onMove: (e: MouseEvent) => void;
|
||||
onLeave: () => void;
|
||||
}
|
||||
|
||||
function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: HistogramOpts): void {
|
||||
const container = svg.parentElement;
|
||||
const width = container ? container.clientWidth : 400;
|
||||
const innerW = width - MARGIN.left - MARGIN.right;
|
||||
const innerH = CHART_HEIGHT - MARGIN.top - MARGIN.bottom;
|
||||
|
||||
const root = d3.select(svg);
|
||||
root.selectAll("*").remove();
|
||||
root.attr("viewBox", `0 0 ${width} ${CHART_HEIGHT}`).attr("preserveAspectRatio", "xMidYMid meet");
|
||||
|
||||
const defs = root.append("defs");
|
||||
const grad = defs
|
||||
.append("linearGradient")
|
||||
.attr("id", "compact-bar-grad")
|
||||
.attr("x1", "0%")
|
||||
.attr("y1", "0%")
|
||||
.attr("x2", "0%")
|
||||
.attr("y2", "100%");
|
||||
grad.append("stop").attr("offset", "0%").attr("stop-color", "#818cf8");
|
||||
grad
|
||||
.append("stop")
|
||||
.attr("offset", "100%")
|
||||
.attr("stop-color", "#3730a3")
|
||||
.attr("stop-opacity", 0.7);
|
||||
|
||||
const g = root.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
|
||||
|
||||
const maxSessions = d3.max(histo, (d) => d.sessions) ?? 1;
|
||||
|
||||
const xScale = d3
|
||||
.scaleBand<number>()
|
||||
.domain(histo.map((d) => d.count))
|
||||
.range([0, innerW])
|
||||
.padding(histo.length > 12 ? 0.18 : 0.34);
|
||||
|
||||
const yScale = d3.scaleLinear().domain([0, maxSessions]).nice().range([innerH, 0]);
|
||||
|
||||
// Horizontal grid lines (integer session counts)
|
||||
const yTicks = yScale.ticks(Math.min(4, maxSessions)).filter((d) => Number.isInteger(d));
|
||||
g.selectAll<SVGLineElement, number>(".grid-line")
|
||||
.data(yTicks)
|
||||
.join("line")
|
||||
.attr("class", "grid-line")
|
||||
.attr("x1", 0)
|
||||
.attr("x2", innerW)
|
||||
.attr("y1", (d) => yScale(d))
|
||||
.attr("y2", (d) => yScale(d))
|
||||
.attr("stroke", "#2a2a3d")
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Y axis (sessions)
|
||||
g.append("g")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickValues(yTicks)
|
||||
.tickSize(0)
|
||||
.tickPadding(8)
|
||||
.tickFormat((d) => String(d))
|
||||
)
|
||||
.call((ax) => ax.select(".domain").remove())
|
||||
.selectAll("text")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-family", "Inter, sans-serif");
|
||||
|
||||
// X axis (compactions per session - one tick per bucket)
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${innerH})`)
|
||||
.call(d3.axisBottom(xScale).tickSize(0).tickPadding(8))
|
||||
.call((ax) => ax.select(".domain").remove())
|
||||
.selectAll("text")
|
||||
.attr("fill", "#9ca3af")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-family", "Inter, sans-serif");
|
||||
|
||||
// Axis titles
|
||||
g.append("text")
|
||||
.attr("x", innerW / 2)
|
||||
.attr("y", innerH + 38)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", 500)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(opts.x);
|
||||
|
||||
g.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -innerH / 2)
|
||||
.attr("y", -38)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", 500)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(opts.y);
|
||||
|
||||
// Bars + count labels + rich hover tooltip (full-height hit-area so every
|
||||
// column - including empty buckets - responds, matching the other charts).
|
||||
histo.forEach((d) => {
|
||||
const bx = xScale(d.count);
|
||||
if (bx === undefined) return;
|
||||
const bw = xScale.bandwidth();
|
||||
const by = yScale(d.sessions);
|
||||
const barH = innerH - by;
|
||||
|
||||
const bg = g.append("g");
|
||||
let bar: d3.Selection<SVGRectElement, unknown, null, undefined> | null = null;
|
||||
|
||||
if (d.sessions > 0) {
|
||||
bar = bg
|
||||
.append("rect")
|
||||
.attr("x", bx)
|
||||
.attr("y", by)
|
||||
.attr("width", bw)
|
||||
.attr("height", barH)
|
||||
.attr("rx", Math.min(4, bw / 2))
|
||||
.attr("fill", "url(#compact-bar-grad)")
|
||||
.style("transition", "fill 120ms ease");
|
||||
|
||||
bg.append("text")
|
||||
.attr("x", bx + bw / 2)
|
||||
.attr("y", by - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#a5b4fc")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", "600")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("pointer-events", "none")
|
||||
.text(d.sessions);
|
||||
} else {
|
||||
// Empty bucket: faint baseline tick so the gap reads as "zero", not missing
|
||||
bg.append("rect")
|
||||
.attr("x", bx)
|
||||
.attr("y", innerH - 1)
|
||||
.attr("width", bw)
|
||||
.attr("height", 1)
|
||||
.attr("fill", "#2a2a3d");
|
||||
}
|
||||
|
||||
// Transparent, full-height hover target on top of the bar.
|
||||
bg.append("rect")
|
||||
.attr("x", bx)
|
||||
.attr("y", 0)
|
||||
.attr("width", bw)
|
||||
.attr("height", innerH)
|
||||
.attr("fill", "transparent")
|
||||
.style("cursor", "pointer")
|
||||
.on("mouseenter", (event: MouseEvent) => {
|
||||
if (bar) bar.attr("fill", "#a5b4fc");
|
||||
opts.onHover(event, d);
|
||||
})
|
||||
.on("mousemove", (event: MouseEvent) => opts.onMove(event))
|
||||
.on("mouseleave", () => {
|
||||
if (bar) bar.attr("fill", "url(#compact-bar-grad)");
|
||||
opts.onLeave();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Stat box ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatBoxProps {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
accent?: string;
|
||||
}
|
||||
|
||||
function StatBox({ label, value, sub, accent = "text-accent" }: StatBoxProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 bg-surface-3 border border-border rounded-xl px-4 py-3.5 flex-1 min-w-0">
|
||||
<span className={`text-2xl font-semibold tabular-nums ${accent}`}>{value}</span>
|
||||
<span className="text-[11px] font-medium text-fg-muted uppercase tracking-wider leading-tight">
|
||||
{label}
|
||||
</span>
|
||||
{sub && <span className="text-[11px] text-fg-muted tabular-nums">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompactionImpactProps {
|
||||
data: CompactionImpactData;
|
||||
}
|
||||
|
||||
export function CompactionImpact({ data }: CompactionImpactProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [tip, setTip] = useState<{ x: number; y: number; title: string; detail: string } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const hasData = data.totalCompactions > 0;
|
||||
const affected = data.sessionsWithCompactions;
|
||||
const sessionPct = data.totalSessions > 0 ? Math.round((affected / data.totalSessions) * 100) : 0;
|
||||
const avgPerSession = affected > 0 ? data.totalCompactions / affected : 0;
|
||||
const peak = data.perSession.reduce((m, s) => Math.max(m, s.compactions), 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !hasData) return;
|
||||
const histo = toHistogram(data.perSession);
|
||||
const affectedN = data.sessionsWithCompactions;
|
||||
renderHistogram(svgRef.current, histo, {
|
||||
x: t("compaction.xAxis"),
|
||||
y: t("compaction.yAxis"),
|
||||
onHover: (e, d) => {
|
||||
const pct = affectedN > 0 ? Math.round((d.sessions / affectedN) * 100) : 0;
|
||||
setTip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
title: t("compaction.tipTitle", { count: d.count }),
|
||||
detail: t("compaction.tipDetail", { sessions: d.sessions, pct }),
|
||||
});
|
||||
},
|
||||
onMove: (e) => setTip((p) => (p ? { ...p, x: e.clientX, y: e.clientY } : p)),
|
||||
onLeave: () => setTip(null),
|
||||
});
|
||||
}, [data, hasData, t]);
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-fg-muted">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 17H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v3" />
|
||||
<path d="M13 21l2-2 4 4" />
|
||||
<path d="M17 21v-6" />
|
||||
<path d="M21 17h-6" />
|
||||
</svg>
|
||||
<span className="text-sm">{t("compaction.noData")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* What compaction is - one line so the numbers below make sense */}
|
||||
<p className="text-xs text-fg-muted leading-relaxed">{t("compaction.help")}</p>
|
||||
|
||||
{/* Stat tiles */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatBox
|
||||
label={t("compaction.totalCompactions")}
|
||||
value={data.totalCompactions.toLocaleString()}
|
||||
accent="text-accent-hover"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.sessionsAffected")}
|
||||
value={affected.toLocaleString()}
|
||||
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
|
||||
accent="text-violet-300"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.avgPerSession")}
|
||||
value={avgPerSession.toFixed(1)}
|
||||
accent="text-blue-400"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.peakSession")}
|
||||
value={peak.toLocaleString()}
|
||||
accent="text-status-success"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Histogram: sessions by compaction count */}
|
||||
<div className="w-full overflow-hidden">
|
||||
<p className="text-xs font-medium text-fg-muted uppercase tracking-wider mb-2">
|
||||
{t("compaction.distribution")}
|
||||
</p>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="w-full"
|
||||
style={{ height: CHART_HEIGHT }}
|
||||
aria-label={t("compaction.ariaLabel")}
|
||||
role="img"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Plain-English summary + (when present) tokens freed */}
|
||||
<p className="text-xs text-fg-muted leading-relaxed">
|
||||
{t("compaction.summary", {
|
||||
affected: affected.toLocaleString(),
|
||||
total: data.totalSessions.toLocaleString(),
|
||||
pct: sessionPct,
|
||||
})}
|
||||
{data.tokensRecovered > 0 && (
|
||||
<> {t("compaction.tokensFreed", { tokens: fmtTokens(data.tokensRecovered) })}</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Hover tooltip (matches the app's other chart tooltips) */}
|
||||
{tip && (
|
||||
<div
|
||||
className="fixed z-50 pointer-events-none rounded-md border border-[#2a2a4a] bg-[#12121f] px-2.5 py-1.5 text-xs shadow-xl"
|
||||
style={{
|
||||
left: tip.x > window.innerWidth - 220 ? tip.x - 14 : tip.x + 14,
|
||||
top: tip.y - 10,
|
||||
transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-fg-primary">{tip.title}</div>
|
||||
<div className="mt-0.5 text-fg-secondary">{tip.detail}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
/**
|
||||
* @file ConcurrencyTimeline.tsx
|
||||
* @description Defines the ConcurrencyTimeline component that visualizes concurrency data for agent sessions using horizontal bars. Each lane represents an agent type (main or subagent) with the bar width proportional to the number of sessions and timing indicated as a percentage of the session duration. The component handles empty states gracefully and assigns distinct colors to different agent types for clarity.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ConcurrencyTimelineProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ConcurrencyTimeline` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `laneColor` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ConcurrencyTimelineProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ConcurrencyTimeline**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **laneColor**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ConcurrencyData, ConcurrencyLane } from "../../lib/types";
|
||||
|
||||
// ── Color palette ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MAIN_COLOR = "#6366f1"; // indigo
|
||||
|
||||
const SUBAGENT_PALETTE = [
|
||||
"#10b981", // emerald
|
||||
"#3b82f6", // blue
|
||||
"#f59e0b", // amber
|
||||
"#f43f5e", // rose
|
||||
"#06b6d4", // cyan
|
||||
"#f97316", // orange
|
||||
"#a855f7", // purple
|
||||
"#84cc16", // lime
|
||||
];
|
||||
|
||||
// ── Lane row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface LaneRowProps {
|
||||
lane: ConcurrencyLane;
|
||||
color: string;
|
||||
maxCount: number;
|
||||
onShowTip: (lane: ConcurrencyLane, color: string, anchor: HTMLElement) => void;
|
||||
onHideTip: () => void;
|
||||
}
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function describeLaneTiming(start: number, end: number, t: TFn): string {
|
||||
// start/end are 0–1 fractions of session timeline.
|
||||
const startPct = Math.round(start * 100);
|
||||
const endPct = Math.round(end * 100);
|
||||
const span = Math.max(0, endPct - startPct);
|
||||
|
||||
if (startPct < 15 && endPct > 85) return t("concurrency.tooltip.timing.wholeSession");
|
||||
if (startPct < 15) return t("concurrency.tooltip.timing.frontLoadedFmt", { end: endPct });
|
||||
if (endPct > 85) return t("concurrency.tooltip.timing.backLoadedFmt", { start: startPct });
|
||||
if (span < 15) return t("concurrency.tooltip.timing.tightFmt", { start: startPct, end: endPct });
|
||||
return t("concurrency.tooltip.timing.midSessionFmt", { start: startPct, end: endPct });
|
||||
}
|
||||
|
||||
function LaneRow({ lane, color, maxCount, onShowTip, onHideTip }: LaneRowProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const displayName = lane.name === "Main Agent" ? t("orchestration.mainAgent") : lane.name;
|
||||
// Bar width proportional to session count (the metric with meaningful variance)
|
||||
const barPct = maxCount > 0 ? (lane.count / maxCount) * 100 : 0;
|
||||
|
||||
// Duration as percentage of session (backend returns 0-1 fractions)
|
||||
const startPct = (lane.avgStart * 100).toFixed(0);
|
||||
const endPct = (lane.avgEnd * 100).toFixed(0);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5 group">
|
||||
{/* Label column */}
|
||||
<div className="flex-shrink-0 w-[140px] text-right" title={displayName}>
|
||||
<span className="text-xs font-medium text-fg-secondary truncate block group-hover:text-fg-secondary transition-colors">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bar area */}
|
||||
<div
|
||||
className="relative flex-1 h-6 bg-surface-3 rounded overflow-hidden"
|
||||
onMouseEnter={(e) => onShowTip(lane, color, e.currentTarget)}
|
||||
onMouseLeave={onHideTip}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 rounded transition-all duration-300"
|
||||
style={{
|
||||
width: `${barPct}%`,
|
||||
minWidth: barPct > 0 ? "4px" : undefined,
|
||||
backgroundColor: color,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
{/* Count label inside bar if wide enough, outside if not */}
|
||||
<span
|
||||
className="absolute top-0 bottom-0 flex items-center text-[11px] font-medium tabular-nums"
|
||||
style={{
|
||||
left: barPct > 15 ? "8px" : `calc(${barPct}% + 6px)`,
|
||||
color: barPct > 15 ? "white" : "var(--color-gray-400)",
|
||||
}}
|
||||
>
|
||||
{lane.count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timing range */}
|
||||
<div className="flex-shrink-0 w-[72px] text-[11px] text-fg-muted tabular-nums">
|
||||
{startPct}%–{endPct}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildLaneTooltip(
|
||||
el: HTMLDivElement,
|
||||
lane: ConcurrencyLane,
|
||||
displayName: string,
|
||||
color: string,
|
||||
t: TFn
|
||||
) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
|
||||
const startPct = (lane.avgStart * 100).toFixed(0);
|
||||
const endPct = (lane.avgEnd * 100).toFixed(0);
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:4px";
|
||||
const dot = document.createElement("span");
|
||||
dot.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:9999px;background:${color}`;
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0";
|
||||
title.textContent = displayName;
|
||||
header.appendChild(dot);
|
||||
header.appendChild(title);
|
||||
el.appendChild(header);
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.style.cssText =
|
||||
"font-size:10px;color:#64748b;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
||||
subtitle.textContent = t("concurrency.tooltip.lane");
|
||||
el.appendChild(subtitle);
|
||||
|
||||
const addRow = (label: string, value: string) => {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText =
|
||||
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.style.color = "#64748b";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.textContent = value;
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(val);
|
||||
el.appendChild(row);
|
||||
};
|
||||
|
||||
addRow(t("concurrency.tooltip.sessionsWith"), String(lane.count));
|
||||
addRow(t("concurrency.tooltip.avgStart"), `${startPct}%`);
|
||||
addRow(t("concurrency.tooltip.avgEnd"), `${endPct}%`);
|
||||
|
||||
const desc = document.createElement("p");
|
||||
desc.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:8px 0 0";
|
||||
desc.textContent = describeLaneTiming(lane.avgStart, lane.avgEnd, t);
|
||||
el.appendChild(desc);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.style.cssText = "font-size:10px;color:#64748b;line-height:1.45;margin:6px 0 0";
|
||||
hint.textContent = t("concurrency.tooltip.barHint");
|
||||
el.appendChild(hint);
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-fg-muted"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="4" rx="1" />
|
||||
<rect x="3" y="10" width="12" height="4" rx="1" />
|
||||
<rect x="3" y="16" width="15" height="4" rx="1" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-fg-secondary">{t("concurrency.noData")}</p>
|
||||
<p className="text-xs text-fg-muted mt-1">{t("concurrency.noDataDesc")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public component ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ConcurrencyTimelineProps {
|
||||
data: ConcurrencyData;
|
||||
}
|
||||
|
||||
export function ConcurrencyTimeline({ data }: ConcurrencyTimelineProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
const lanes = data.aggregateLanes;
|
||||
|
||||
const hideTip = useCallback(() => {
|
||||
const tip = tipRef.current;
|
||||
if (tip) tip.style.opacity = "0";
|
||||
}, []);
|
||||
|
||||
const showTip = useCallback(
|
||||
(lane: ConcurrencyLane, color: string, anchor: HTMLElement) => {
|
||||
const tip = tipRef.current;
|
||||
if (!tip) return;
|
||||
const displayName = lane.name === "Main Agent" ? t("orchestration.mainAgent") : lane.name;
|
||||
buildLaneTooltip(tip, lane, displayName, color, t);
|
||||
|
||||
const r = anchor.getBoundingClientRect();
|
||||
tip.style.opacity = "0";
|
||||
tip.style.display = "block";
|
||||
const tipW = tip.offsetWidth || 280;
|
||||
const tipH = tip.offsetHeight || 160;
|
||||
const margin = 8;
|
||||
|
||||
let left = r.left + r.width / 2 - tipW / 2;
|
||||
if (left < margin) left = margin;
|
||||
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
|
||||
let top = r.top - tipH - 10;
|
||||
if (top < margin) top = r.bottom + 10;
|
||||
|
||||
tip.style.left = `${left}px`;
|
||||
tip.style.top = `${top}px`;
|
||||
tip.style.opacity = "1";
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
if (lanes.length === 0) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
// Sort by session count descending so the most-used agent types are on top
|
||||
const sorted = [...lanes].sort((a, b) => b.count - a.count);
|
||||
const maxCount = sorted[0]?.count ?? 1;
|
||||
|
||||
// Assign colors
|
||||
let subagentIndex = 0;
|
||||
const coloredLanes = sorted.map((lane) => {
|
||||
const isMain = lane.name === "Main Agent";
|
||||
const color = isMain
|
||||
? MAIN_COLOR
|
||||
: (SUBAGENT_PALETTE[subagentIndex % SUBAGENT_PALETTE.length] ?? MAIN_COLOR);
|
||||
if (!isMain) subagentIndex++;
|
||||
return { lane, color };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full" onMouseLeave={hideTip}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex-shrink-0 w-[140px]" />
|
||||
<div className="flex-1 flex items-center justify-between">
|
||||
<span className="text-[10px] text-fg-muted uppercase tracking-wider">
|
||||
{t("concurrency.sessions")}
|
||||
</span>
|
||||
<span className="text-[10px] text-fg-muted tabular-nums">
|
||||
{maxCount}
|
||||
{t("concurrency.max")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-[72px] text-[10px] text-fg-muted uppercase tracking-wider">
|
||||
{t("concurrency.timing")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lane rows */}
|
||||
<div className="flex flex-col divide-y divide-surface-4">
|
||||
{coloredLanes.map(({ lane, color }) => (
|
||||
<LaneRow
|
||||
key={lane.name}
|
||||
lane={lane}
|
||||
color={color}
|
||||
maxCount={maxCount}
|
||||
onShowTip={showTip}
|
||||
onHideTip={hideTip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
aria-hidden="true"
|
||||
className="fixed z-50 px-3 py-2 rounded-lg shadow-2xl pointer-events-none"
|
||||
style={{
|
||||
display: "none",
|
||||
opacity: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
background: "#12121f",
|
||||
border: "1px solid #2a2a4a",
|
||||
color: "#e2e8f0",
|
||||
minWidth: 240,
|
||||
maxWidth: 320,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export helper so callers can import the color fn if needed
|
||||
export function laneColor(name: string, subagentIndex: number): string {
|
||||
if (name === "Main Agent") return MAIN_COLOR;
|
||||
return SUBAGENT_PALETTE[subagentIndex % SUBAGENT_PALETTE.length] ?? MAIN_COLOR;
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
/**
|
||||
* @file ErrorPropagationMap.tsx
|
||||
* @description A React component that visualizes error propagation across agent hierarchies in a workflow system. It displays the distribution of errors by hierarchy depth, identifies error-prone agent types, and highlights API and session errors. The component uses horizontal bars to represent error counts at different depths and types, providing an intuitive overview of where errors are occurring within the agent structure.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ErrorPropagationMapProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ErrorPropagationMap` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ErrorPropagationMapProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ErrorPropagationMap**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ErrorPropagationData } from "../../lib/types";
|
||||
|
||||
const DEPTH_COLORS = ["#ef4444", "#f97316", "#eab308", "#a855f7"];
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ErrorPropagationMapProps {
|
||||
data: ErrorPropagationData;
|
||||
}
|
||||
|
||||
export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [hoveredDepth, setHoveredDepth] = useState<number | null>(null);
|
||||
|
||||
function depthLabel(depth: number): string {
|
||||
const keys = [
|
||||
t("errorPropagation.depthLabels.sessionMain"),
|
||||
t("errorPropagation.depthLabels.directSubagent"),
|
||||
t("errorPropagation.depthLabels.nested"),
|
||||
t("errorPropagation.depthLabels.deep"),
|
||||
];
|
||||
return keys[depth] ?? `${t("common:depth")} ${depth}`;
|
||||
}
|
||||
|
||||
const hasErrors =
|
||||
data.byDepth.some((d) => d.count > 0) ||
|
||||
data.byType.some((t) => t.count > 0) ||
|
||||
(data.eventErrors && data.eventErrors.length > 0) ||
|
||||
data.sessionsWithErrors > 0;
|
||||
|
||||
if (!hasErrors) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-status-success/10 border border-status-success/20 flex items-center justify-center">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-status-success font-medium">
|
||||
{t("errorPropagation.noErrors")}
|
||||
</span>
|
||||
<span className="text-xs text-fg-muted">{t("errorPropagation.allSuccess")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const errorRatePct = data.errorRate;
|
||||
const totalErrors = data.byDepth.reduce((s, d) => s + d.count, 0);
|
||||
const maxDepthCount = Math.max(...data.byDepth.map((d) => d.count), 1);
|
||||
const topTypes = [...data.byType].sort((a, b) => b.count - a.count).slice(0, 6);
|
||||
const hasDepthData = data.byDepth.some((d) => d.count > 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Error rate summary bar */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-status-danger/5 border border-status-danger/15">
|
||||
<div className="flex-shrink-0 min-w-[2.75rem] h-10 px-2 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center">
|
||||
<span className="text-[13px] font-bold text-status-danger tabular-nums whitespace-nowrap">
|
||||
{errorRatePct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-status-danger">
|
||||
{t("errorPropagation.sessionsErrorSummary", {
|
||||
errorSessions: data.sessionsWithErrors,
|
||||
totalSessions: data.totalSessions,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-[11px] text-fg-muted mt-0.5">
|
||||
{totalErrors > 0
|
||||
? `${totalErrors}${t("errorPropagation.agentErrors")}`
|
||||
: t("errorPropagation.sessionErrorsOnly")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors by depth - horizontal bars */}
|
||||
{hasDepthData && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-fg-muted uppercase tracking-wider mb-2.5">
|
||||
{t("errorPropagation.errorsByDepth")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{data.byDepth
|
||||
.filter((d) => d.count > 0)
|
||||
.map((d) => {
|
||||
const pct = (d.count / maxDepthCount) * 100;
|
||||
const color = DEPTH_COLORS[d.depth] ?? DEPTH_COLORS[DEPTH_COLORS.length - 1];
|
||||
const isHovered = hoveredDepth === d.depth;
|
||||
return (
|
||||
<div
|
||||
key={d.depth}
|
||||
className="flex items-center gap-2.5 group"
|
||||
onMouseEnter={() => setHoveredDepth(d.depth)}
|
||||
onMouseLeave={() => setHoveredDepth(null)}
|
||||
>
|
||||
<span className="text-[11px] text-fg-muted w-24 flex-shrink-0 text-right truncate">
|
||||
{depthLabel(d.depth)}
|
||||
</span>
|
||||
<div className="flex-1 h-5 bg-surface-3 rounded overflow-hidden relative">
|
||||
<div
|
||||
className="h-full rounded transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.max(pct, 4)}%`,
|
||||
backgroundColor: color,
|
||||
opacity: isHovered ? 1 : 0.75,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-semibold tabular-nums w-7 text-right transition-colors"
|
||||
style={{ color: isHovered ? color : "#9ca3af" }}
|
||||
>
|
||||
{d.count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error-prone agent types */}
|
||||
{topTypes.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-fg-muted uppercase tracking-wider mb-2.5">
|
||||
{t("errorPropagation.errorProneTypes")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{topTypes.map((t, i) => {
|
||||
const maxCount = topTypes[0]?.count ?? 1;
|
||||
const pct = (t.count / maxCount) * 100;
|
||||
return (
|
||||
<div
|
||||
key={t.subagent_type}
|
||||
className="flex items-center gap-2.5 px-2.5 py-1.5 rounded-lg hover:bg-surface-3/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: DEPTH_COLORS[Math.min(i, DEPTH_COLORS.length - 1)] }}
|
||||
/>
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{t.subagent_type}
|
||||
</span>
|
||||
<div className="w-16 h-1.5 bg-surface-4 rounded-full overflow-hidden flex-shrink-0">
|
||||
<div
|
||||
className="h-full rounded-full bg-status-danger/60"
|
||||
style={{ width: `${Math.max(pct, 8)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-status-danger tabular-nums w-5 text-right flex-shrink-0">
|
||||
{t.count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API & session errors */}
|
||||
{data.eventErrors && data.eventErrors.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-fg-muted uppercase tracking-wider mb-2.5">
|
||||
{t("errorPropagation.apiSessionErrors")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{data.eventErrors.map((e) => (
|
||||
<div
|
||||
key={e.summary}
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg bg-status-warning/5 border border-status-warning/10 hover:border-status-warning/20 transition-colors"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<span
|
||||
className="text-xs text-fg-secondary truncate flex-1 min-w-0"
|
||||
title={e.summary}
|
||||
>
|
||||
{e.summary}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-[11px] font-semibold text-status-warning tabular-nums">
|
||||
{e.count}x
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @file LanePipelinePanel.tsx
|
||||
* @description Read-only view of this dashboard's OWN pipelines on the Workflows
|
||||
* page: a horizontally scrolling strip of lane tiles, and the selected lane's
|
||||
* pipeline map underneath. It reuses the Workspace's `LaneStripCard` and
|
||||
* `PipelineMap` so a stage reads identically on both pages. Read-only on
|
||||
* purpose — the console-never-writes-a-stage boundary applies here too, so this
|
||||
* panel only observes; every lane action stays on the Workspace page.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import LaneStripCard from "../lanes/LaneStripCard";
|
||||
import PipelineMap from "../lanes/PipelineMap";
|
||||
|
||||
/**
|
||||
* Lane strip plus the selected lane's pipeline map.
|
||||
* @returns The panel element; an empty-state hint when no lane exists yet.
|
||||
*/
|
||||
export function LanePipelinePanel() {
|
||||
const { t } = useTranslation(["workflows", "lanes"]);
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.lanes.list();
|
||||
setLanes(r.lanes);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("workflows:failedLoad"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Lanes move as hooks land; debounce so a burst of events costs one fetch.
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const unsub = eventBus.subscribe(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void refresh(), 3000);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
// Follow the list rather than pinning an id: a lane can be removed elsewhere.
|
||||
const selected = lanes.find((l) => l.id === selectedId) ?? lanes[0] ?? null;
|
||||
|
||||
if (error) return <p className="text-xs text-status-danger">{error}</p>;
|
||||
|
||||
if (!lanes.length) {
|
||||
return (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t("lanes:emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div data-testid="workflows-lane-strip" className="flex flex-row gap-2 overflow-x-auto pb-1">
|
||||
{lanes.map((l) => (
|
||||
<LaneStripCard
|
||||
key={l.id}
|
||||
lane={l}
|
||||
selected={selected?.id === l.id}
|
||||
onSelect={() => setSelectedId(l.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div data-testid="workflows-lane-pipeline" className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-fg-primary">
|
||||
{t("lanes:laneHeader", {
|
||||
id: selected.id,
|
||||
title: selected.title || selected.cwd,
|
||||
pipeline: selected.pipeline_name,
|
||||
})}
|
||||
</span>
|
||||
<Link
|
||||
to="/run"
|
||||
className="ml-auto inline-flex items-center gap-1 text-xs text-fg-muted hover:text-accent"
|
||||
>
|
||||
{t("workflows:lanes.openWorkspace")}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<PipelineMap nodes={selected.pipeline_nodes} detectedSignal={selected.detected_signal} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,532 +0,0 @@
|
||||
/**
|
||||
* @file ModelDelegationFlow.tsx
|
||||
* @description Defines the ModelDelegationFlow React component that visualizes the relationships between main models and subagent models in a flow diagram using D3.js. The component takes model delegation data as input and renders an SVG diagram that shows how different models are connected based on their usage in agents and sessions. It categorizes models into families (opus, sonnet, haiku, other) for color-coding and provides a clear visual representation of model delegation patterns.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
* - `../../lib/format`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ModelDelegationFlowProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ModelDelegationFlow` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ModelDelegationFlowProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ModelDelegationFlow**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
import type { ModelDelegationData } from "../../lib/types";
|
||||
import { formatModelName } from "../../lib/format";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function modelFamily(name: string): "opus" | "sonnet" | "haiku" | "other" {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("opus")) return "opus";
|
||||
if (lower.includes("sonnet")) return "sonnet";
|
||||
if (lower.includes("haiku")) return "haiku";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// Model formatting is handled by the shared formatModelName utility.
|
||||
|
||||
// ── Color palette per model family ───────────────────────────────────────────
|
||||
|
||||
const FAMILY_COLORS = {
|
||||
opus: {
|
||||
grad: ["#7c3aed", "#a855f7"] as [string, string],
|
||||
stroke: "#a855f7",
|
||||
text: "#e9d5ff",
|
||||
badge: "rgba(168,85,247,0.15)",
|
||||
},
|
||||
sonnet: {
|
||||
grad: ["#1d4ed8", "#3b82f6"] as [string, string],
|
||||
stroke: "#3b82f6",
|
||||
text: "#bfdbfe",
|
||||
badge: "rgba(59,130,246,0.15)",
|
||||
},
|
||||
haiku: {
|
||||
grad: ["#065f46", "#10b981"] as [string, string],
|
||||
stroke: "#10b981",
|
||||
text: "#a7f3d0",
|
||||
badge: "rgba(16,185,129,0.15)",
|
||||
},
|
||||
other: {
|
||||
grad: ["#374151", "#6b7280"] as [string, string],
|
||||
stroke: "#6b7280",
|
||||
text: "#d1d5db",
|
||||
badge: "rgba(107,114,128,0.15)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── Types used internally ─────────────────────────────────────────────────────
|
||||
|
||||
interface NodeDatum {
|
||||
id: string;
|
||||
label: string;
|
||||
family: "opus" | "sonnet" | "haiku" | "other";
|
||||
agentCount: number;
|
||||
sessionCount: number;
|
||||
totalTokens: number;
|
||||
side: "main" | "sub";
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface EdgeDatum {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
type ShowTipFn = (node: NodeDatum, anchor: SVGGraphicsElement) => void;
|
||||
type HideTipFn = () => void;
|
||||
|
||||
// ── D3 chart renderer ─────────────────────────────────────────────────────────
|
||||
|
||||
const NODE_W = 160;
|
||||
const NODE_H = 80;
|
||||
const NODE_RX = 10;
|
||||
const COL_GAP = 200;
|
||||
const ROW_GAP = 108;
|
||||
const PADDING = { top: 40, left: 24, right: 24, bottom: 24 };
|
||||
|
||||
function renderFlow(
|
||||
svg: SVGSVGElement,
|
||||
mainNodes: NodeDatum[],
|
||||
subNodes: NodeDatum[],
|
||||
edges: EdgeDatum[],
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
showTip: ShowTipFn,
|
||||
hideTip: HideTipFn
|
||||
): void {
|
||||
const allNodes = [...mainNodes, ...subNodes];
|
||||
|
||||
const totalRows = Math.max(mainNodes.length, subNodes.length);
|
||||
const chartH = totalRows * ROW_GAP + PADDING.top + PADDING.bottom;
|
||||
const chartW = NODE_W * 2 + COL_GAP + PADDING.left + PADDING.right;
|
||||
|
||||
const root = d3.select(svg);
|
||||
root.selectAll("*").remove();
|
||||
root.attr("viewBox", `0 0 ${chartW} ${chartH}`).attr("preserveAspectRatio", "xMidYMid meet");
|
||||
|
||||
const defs = root.append("defs");
|
||||
|
||||
// Gradient defs per family
|
||||
(["opus", "sonnet", "haiku", "other"] as const).forEach((fam) => {
|
||||
const colors = FAMILY_COLORS[fam];
|
||||
const grad = defs
|
||||
.append("linearGradient")
|
||||
.attr("id", `flow-grad-${fam}`)
|
||||
.attr("x1", "0%")
|
||||
.attr("y1", "0%")
|
||||
.attr("x2", "100%")
|
||||
.attr("y2", "100%");
|
||||
grad.append("stop").attr("offset", "0%").attr("stop-color", colors.grad[0]);
|
||||
grad.append("stop").attr("offset", "100%").attr("stop-color", colors.grad[1]);
|
||||
});
|
||||
|
||||
const g = root.append("g");
|
||||
|
||||
// Column labels
|
||||
const labelY = PADDING.top - 16;
|
||||
g.append("text")
|
||||
.attr("x", PADDING.left + NODE_W / 2)
|
||||
.attr("y", labelY)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 11)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("letter-spacing", "0.08em")
|
||||
.text(t("modelDelegation.mainModels"));
|
||||
|
||||
g.append("text")
|
||||
.attr("x", PADDING.left + NODE_W + COL_GAP + NODE_W / 2)
|
||||
.attr("y", labelY)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 11)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("letter-spacing", "0.08em")
|
||||
.text(t("modelDelegation.subagentModels"));
|
||||
|
||||
// Build lookup for node positions
|
||||
const nodeMap = new Map<string, NodeDatum>(allNodes.map((n) => [n.id, n]));
|
||||
|
||||
// Draw edges (cubic bezier curves)
|
||||
edges.forEach(({ sourceId, targetId }) => {
|
||||
const src = nodeMap.get(sourceId);
|
||||
const tgt = nodeMap.get(targetId);
|
||||
if (!src || !tgt) return;
|
||||
|
||||
const x1 = src.x + NODE_W;
|
||||
const y1 = src.y + NODE_H / 2;
|
||||
const x2 = tgt.x;
|
||||
const y2 = tgt.y + NODE_H / 2;
|
||||
const cx = (x1 + x2) / 2;
|
||||
|
||||
g.append("path")
|
||||
.attr("d", `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#2a2a3d")
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("opacity", 0.7);
|
||||
});
|
||||
|
||||
// Draw nodes
|
||||
allNodes.forEach((node) => {
|
||||
const colors = FAMILY_COLORS[node.family];
|
||||
const ng = g
|
||||
.append("g")
|
||||
.attr("transform", `translate(${node.x},${node.y})`)
|
||||
.style("cursor", "default")
|
||||
.on("mouseenter", function () {
|
||||
showTip(node, this as SVGGraphicsElement);
|
||||
})
|
||||
.on("mouseleave", () => hideTip());
|
||||
|
||||
// Border glow rect (slightly larger)
|
||||
ng.append("rect")
|
||||
.attr("x", -1)
|
||||
.attr("y", -1)
|
||||
.attr("width", NODE_W + 2)
|
||||
.attr("height", NODE_H + 2)
|
||||
.attr("rx", NODE_RX + 1)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", colors.stroke)
|
||||
.attr("stroke-width", 1)
|
||||
.attr("opacity", 0.25);
|
||||
|
||||
// Main rect with gradient
|
||||
ng.append("rect")
|
||||
.attr("width", NODE_W)
|
||||
.attr("height", NODE_H)
|
||||
.attr("rx", NODE_RX)
|
||||
.attr("fill", `url(#flow-grad-${node.family})`)
|
||||
.attr("fill-opacity", 0.18)
|
||||
.attr("stroke", colors.stroke)
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Model name
|
||||
ng.append("text")
|
||||
.attr("x", 12)
|
||||
.attr("y", 22)
|
||||
.attr("fill", colors.text)
|
||||
.attr("font-size", 11)
|
||||
.attr("font-weight", "600")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(formatModelName(node.label) ?? node.label);
|
||||
|
||||
// Agent count pill
|
||||
ng.append("rect")
|
||||
.attr("x", 10)
|
||||
.attr("y", 32)
|
||||
.attr("width", 64)
|
||||
.attr("height", 16)
|
||||
.attr("rx", 4)
|
||||
.attr("fill", colors.badge);
|
||||
|
||||
ng.append("text")
|
||||
.attr("x", 42)
|
||||
.attr("y", 43.5)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", colors.text)
|
||||
.attr("font-size", 9.5)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(t("modelDelegation.agentsCount", { count: node.agentCount }));
|
||||
|
||||
// Token count
|
||||
if (node.totalTokens > 0) {
|
||||
ng.append("text")
|
||||
.attr("x", 12)
|
||||
.attr("y", 66)
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 9.5)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(t("modelDelegation.tokensCount", { tokens: fmtTokens(node.totalTokens) }));
|
||||
}
|
||||
|
||||
// Session count (main nodes only)
|
||||
if (node.side === "main" && node.sessionCount > 0) {
|
||||
ng.append("text")
|
||||
.attr("x", NODE_W - 10)
|
||||
.attr("y", 66)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 9.5)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(t("modelDelegation.sessionsCount", { count: node.sessionCount }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ModelDelegationFlowProps {
|
||||
data: ModelDelegationData;
|
||||
}
|
||||
|
||||
export function ModelDelegationFlow({ data }: ModelDelegationFlowProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasData = data.mainModels.length > 0 || data.subagentModels.length > 0;
|
||||
const totalAgents = countTotalAgents(data);
|
||||
|
||||
const hideTip = useCallback(() => {
|
||||
const tip = tipRef.current;
|
||||
if (tip) tip.style.opacity = "0";
|
||||
}, []);
|
||||
|
||||
const showTip = useCallback(
|
||||
(node: NodeDatum, anchor: SVGGraphicsElement) => {
|
||||
const tip = tipRef.current;
|
||||
if (!tip) return;
|
||||
buildModelDelegationTooltip(tip, node, totalAgents, t);
|
||||
const r = anchor.getBoundingClientRect();
|
||||
tip.style.opacity = "0";
|
||||
tip.style.display = "block";
|
||||
const tipW = tip.offsetWidth || 280;
|
||||
const tipH = tip.offsetHeight || 160;
|
||||
|
||||
const margin = 8;
|
||||
let left = r.left + r.width / 2 - tipW / 2;
|
||||
if (left < margin) left = margin;
|
||||
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
|
||||
let top = r.top - tipH - 10;
|
||||
if (top < margin) top = r.bottom + 10;
|
||||
|
||||
tip.style.left = `${left}px`;
|
||||
tip.style.top = `${top}px`;
|
||||
tip.style.opacity = "1";
|
||||
},
|
||||
[totalAgents, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !hasData) return;
|
||||
|
||||
const tokenMap = new Map<string, number>();
|
||||
data.tokensByModel.forEach(({ model, input_tokens, output_tokens }) => {
|
||||
tokenMap.set(model, (tokenMap.get(model) ?? 0) + input_tokens + output_tokens);
|
||||
});
|
||||
|
||||
const mainNodes: NodeDatum[] = data.mainModels.map((m, i) => ({
|
||||
id: `main-${m.model}`,
|
||||
label: formatModelName(m.model) ?? m.model,
|
||||
family: modelFamily(m.model),
|
||||
agentCount: m.agent_count,
|
||||
sessionCount: m.session_count,
|
||||
totalTokens: tokenMap.get(m.model) ?? 0,
|
||||
side: "main",
|
||||
x: PADDING.left,
|
||||
y: PADDING.top + i * ROW_GAP,
|
||||
}));
|
||||
|
||||
const subNodes: NodeDatum[] = data.subagentModels.map((m, i) => ({
|
||||
id: `sub-${m.model}`,
|
||||
label: formatModelName(m.model) ?? m.model,
|
||||
family: modelFamily(m.model),
|
||||
agentCount: m.agent_count,
|
||||
sessionCount: 0,
|
||||
totalTokens: tokenMap.get(m.model) ?? 0,
|
||||
side: "sub",
|
||||
x: PADDING.left + NODE_W + COL_GAP,
|
||||
y: PADDING.top + i * ROW_GAP,
|
||||
}));
|
||||
|
||||
// Connect all main models to all subagent models that share a family, or all if no match
|
||||
const edges: EdgeDatum[] = [];
|
||||
mainNodes.forEach((mn) => {
|
||||
subNodes.forEach((sn) => {
|
||||
edges.push({ sourceId: mn.id, targetId: sn.id });
|
||||
});
|
||||
});
|
||||
|
||||
renderFlow(svgRef.current, mainNodes, subNodes, edges, t, showTip, hideTip);
|
||||
hideTip();
|
||||
}, [data, hasData, t, showTip, hideTip]);
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-fg-muted">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
<span className="text-sm">{t("modelDelegation.noData")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full overflow-x-auto relative" onMouseLeave={hideTip}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="w-full"
|
||||
style={{ minHeight: 120 }}
|
||||
aria-label={t("modelDelegation.ariaLabel")}
|
||||
role="img"
|
||||
/>
|
||||
<div
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
aria-hidden="true"
|
||||
className="fixed z-50 px-3 py-2 rounded-lg shadow-2xl pointer-events-none"
|
||||
style={{
|
||||
display: "none",
|
||||
opacity: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
background: "#12121f",
|
||||
border: "1px solid #2a2a4a",
|
||||
color: "#e2e8f0",
|
||||
minWidth: 240,
|
||||
maxWidth: 320,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tooltip helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function countTotalAgents(data: ModelDelegationData): number {
|
||||
const mainSum = data.mainModels.reduce((s, m) => s + m.agent_count, 0);
|
||||
const subSum = data.subagentModels.reduce((s, m) => s + m.agent_count, 0);
|
||||
return mainSum + subSum;
|
||||
}
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function describeFamily(family: NodeDatum["family"], t: TFn): string {
|
||||
switch (family) {
|
||||
case "opus":
|
||||
return t("modelDelegation.tooltip.family.opus");
|
||||
case "sonnet":
|
||||
return t("modelDelegation.tooltip.family.sonnet");
|
||||
case "haiku":
|
||||
return t("modelDelegation.tooltip.family.haiku");
|
||||
case "other":
|
||||
return t("modelDelegation.tooltip.family.other");
|
||||
}
|
||||
}
|
||||
|
||||
function buildModelDelegationTooltip(
|
||||
el: HTMLDivElement,
|
||||
node: NodeDatum,
|
||||
totalAgents: number,
|
||||
t: TFn
|
||||
) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
|
||||
const sharePct = totalAgents > 0 ? `${((node.agentCount / totalAgents) * 100).toFixed(1)}%` : "-";
|
||||
const sideLabel =
|
||||
node.side === "main"
|
||||
? t("modelDelegation.tooltip.mainModel")
|
||||
: t("modelDelegation.tooltip.subagentModel");
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0";
|
||||
title.textContent = node.label;
|
||||
el.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.style.cssText =
|
||||
"font-size:10px;color:#64748b;margin:2px 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
||||
subtitle.textContent = `${sideLabel} · ${node.family}`;
|
||||
el.appendChild(subtitle);
|
||||
|
||||
const addRow = (label: string, value: string) => {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText =
|
||||
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.style.color = "#64748b";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.textContent = value;
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(val);
|
||||
el.appendChild(row);
|
||||
};
|
||||
|
||||
addRow(t("modelDelegation.tooltip.agentRuns"), node.agentCount.toLocaleString());
|
||||
addRow(t("modelDelegation.tooltip.shareOfAll"), sharePct);
|
||||
if (node.side === "main") {
|
||||
addRow(t("modelDelegation.tooltip.sessionsOnModel"), String(node.sessionCount));
|
||||
}
|
||||
addRow(t("modelDelegation.tooltip.totalTokens"), node.totalTokens.toLocaleString());
|
||||
|
||||
const desc = document.createElement("p");
|
||||
desc.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:8px 0 0";
|
||||
desc.textContent = describeFamily(node.family, t);
|
||||
el.appendChild(desc);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.style.cssText = "font-size:11px;color:#64748b;line-height:1.45;margin:6px 0 0";
|
||||
hint.textContent =
|
||||
node.side === "main"
|
||||
? t("modelDelegation.tooltip.lines.main")
|
||||
: t("modelDelegation.tooltip.lines.sub");
|
||||
el.appendChild(hint);
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
/**
|
||||
* @file SessionComplexityScatter.tsx
|
||||
* @description A React component that renders a scatter plot visualization of session complexity using D3.js. Each session is represented as a bubble, where the x-axis represents the session duration, the y-axis represents the number of agents involved, and the size of the bubble corresponds to the total tokens used. The color of each bubble indicates the session status (e.g., completed, active, error, abandoned). The component also includes tooltips for detailed information on hover and a legend for status colors. It is designed to be responsive and provides an empty state when no data is available.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
* - `../../lib/format`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SessionComplexityScatterProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SessionComplexityScatter` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SessionComplexityScatterProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SessionComplexityScatter**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
import type { SessionComplexityItem } from "../../lib/types";
|
||||
import { formatModelName } from "../../lib/format";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MARGIN = { top: 20, right: 24, bottom: 60, left: 52 };
|
||||
const MIN_BUBBLE_R = 4;
|
||||
const MAX_BUBBLE_R = 32;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
completed: "#22c55e",
|
||||
error: "#ef4444",
|
||||
active: "#6366f1",
|
||||
abandoned: "#eab308",
|
||||
};
|
||||
|
||||
function statusColor(status: string): string {
|
||||
return STATUS_COLOR[status] ?? "#6b7280";
|
||||
}
|
||||
|
||||
// ── Duration formatting ───────────────────────────────────────────────────────
|
||||
|
||||
function formatDurationSec(sec: number): string {
|
||||
if (sec < 60) return `${Math.round(sec)}s`;
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
const s = Math.round(sec % 60);
|
||||
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||
}
|
||||
|
||||
function fmtXTick(sec: number): string {
|
||||
if (sec === 0) return "0";
|
||||
if (sec < 60) return `${Math.round(sec)}s`;
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m > 0 ? `${m}m` : ""}`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// ── Tooltip ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
item: SessionComplexityItem;
|
||||
}
|
||||
|
||||
function Tooltip({ state }: { state: TooltipState }) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const nearRight = state.x > window.innerWidth - 220;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||
style={{
|
||||
left: nearRight ? state.x - 12 : state.x + 12,
|
||||
top: state.y - 10,
|
||||
transform: nearRight ? "translateX(-100%)" : undefined,
|
||||
}}
|
||||
>
|
||||
<p className="font-semibold text-fg-primary mb-1 truncate max-w-[180px]">
|
||||
{state.item.name ?? state.item.id.slice(0, 12)}
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5 text-fg-secondary">
|
||||
<span>
|
||||
{t("complexity.tooltip.duration")} {formatDurationSec(state.item.duration)}
|
||||
</span>
|
||||
<span>
|
||||
{t("complexity.tooltip.agents")} {state.item.agentCount}
|
||||
</span>
|
||||
<span>
|
||||
{t("complexity.tooltip.subagents")} {state.item.subagentCount}
|
||||
</span>
|
||||
<span>
|
||||
{t("complexity.tooltip.tokens")} {fmtTokens(state.item.totalTokens)}
|
||||
</span>
|
||||
{state.item.model && (
|
||||
<span>
|
||||
{t("complexity.tooltip.model")} {formatModelName(state.item.model)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 pt-1 border-t border-[#2a2a4a]">
|
||||
<span className="font-medium" style={{ color: statusColor(state.item.status) }}>
|
||||
{t(`common:status.${state.item.status}`, { defaultValue: state.item.status })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Legend ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const LEGEND_STATUSES = ["completed", "active", "error", "abandoned"] as const;
|
||||
|
||||
function Legend() {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 justify-center mt-2">
|
||||
{LEGEND_STATUSES.map((s) => (
|
||||
<div key={s} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: statusColor(s) }}
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t(`common:status.${s}`, { defaultValue: s })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-fg-muted"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<circle cx="7" cy="12" r="3" />
|
||||
<circle cx="17" cy="8" r="2" />
|
||||
<circle cx="14" cy="17" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-fg-secondary">{t("complexity.noData")}</p>
|
||||
<p className="text-xs text-fg-muted mt-1">{t("complexity.noDataDesc")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main chart ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SessionComplexityScatterProps {
|
||||
data: SessionComplexityItem[];
|
||||
onSessionClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function SessionComplexityScatter({ data, onSessionClick }: SessionComplexityScatterProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [width, setWidth] = useState(600);
|
||||
|
||||
// Track container width for responsiveness
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const obs = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w) setWidth(w);
|
||||
});
|
||||
obs.observe(el);
|
||||
setWidth(el.clientWidth);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
const height = Math.max(280, Math.min(420, width * 0.5));
|
||||
|
||||
const handleMouseLeave = useCallback(() => setTooltip(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || data.length === 0) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const innerW = width - MARGIN.left - MARGIN.right;
|
||||
const innerH = height - MARGIN.top - MARGIN.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
|
||||
|
||||
// Scales
|
||||
const xScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, d3.max(data, (d) => d.duration) ?? 1])
|
||||
.nice()
|
||||
.range([0, innerW]);
|
||||
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, (d3.max(data, (d) => d.agentCount) ?? 1) + 1])
|
||||
.nice()
|
||||
.range([innerH, 0]);
|
||||
|
||||
const rScale = d3
|
||||
.scaleSqrt()
|
||||
.domain([0, d3.max(data, (d) => d.totalTokens) ?? 1])
|
||||
.range([MIN_BUBBLE_R, MAX_BUBBLE_R]);
|
||||
|
||||
// Grid lines
|
||||
const gridColor = "#2a2a3d";
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "grid-x")
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(xScale)
|
||||
.ticks(5)
|
||||
.tickSize(-innerH)
|
||||
.tickFormat(() => "")
|
||||
)
|
||||
.attr("transform", `translate(0,${innerH})`)
|
||||
.call((sel) => {
|
||||
sel.select(".domain").remove();
|
||||
sel.selectAll(".tick line").attr("stroke", gridColor).attr("stroke-dasharray", "3,3");
|
||||
});
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "grid-y")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.ticks(5)
|
||||
.tickSize(-innerW)
|
||||
.tickFormat(() => "")
|
||||
)
|
||||
.call((sel) => {
|
||||
sel.select(".domain").remove();
|
||||
sel.selectAll(".tick line").attr("stroke", gridColor).attr("stroke-dasharray", "3,3");
|
||||
});
|
||||
|
||||
// X axis
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${innerH})`)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(xScale)
|
||||
.ticks(5)
|
||||
.tickFormat((d) => fmtXTick(d as number))
|
||||
)
|
||||
.call((sel) => {
|
||||
sel.select(".domain").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick line").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
|
||||
});
|
||||
|
||||
// X axis label
|
||||
g.append("text")
|
||||
.attr("x", innerW / 2)
|
||||
.attr("y", innerH + 44)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", "11")
|
||||
.text(t("complexity.duration"));
|
||||
|
||||
// Y axis
|
||||
g.append("g")
|
||||
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format("d")))
|
||||
.call((sel) => {
|
||||
sel.select(".domain").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick line").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
|
||||
});
|
||||
|
||||
// Y axis label
|
||||
g.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -innerH / 2)
|
||||
.attr("y", -40)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", "11")
|
||||
.text(t("complexity.agentCount"));
|
||||
|
||||
// Bubbles - sort largest to back so small ones are clickable
|
||||
const sorted = [...data].sort((a, b) => b.totalTokens - a.totalTokens);
|
||||
|
||||
g.selectAll<SVGCircleElement, SessionComplexityItem>("circle")
|
||||
.data(sorted)
|
||||
.join("circle")
|
||||
.attr("cx", (d) => xScale(d.duration))
|
||||
.attr("cy", (d) => yScale(d.agentCount))
|
||||
.attr("r", (d) => rScale(d.totalTokens))
|
||||
.attr("fill", (d) => statusColor(d.status))
|
||||
.attr("fill-opacity", 0.75)
|
||||
.attr("stroke", (d) => statusColor(d.status))
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("stroke-opacity", 0.9)
|
||||
.style("cursor", onSessionClick ? "pointer" : "default")
|
||||
.on("mouseenter", (event: MouseEvent, d) => {
|
||||
d3.select(event.currentTarget as SVGCircleElement)
|
||||
.attr("fill-opacity", 1)
|
||||
.attr("stroke-width", 2.5);
|
||||
setTooltip({ x: event.clientX, y: event.clientY, item: d });
|
||||
})
|
||||
.on("mousemove", (event: MouseEvent) => {
|
||||
setTooltip((prev) => (prev ? { ...prev, x: event.clientX, y: event.clientY } : null));
|
||||
})
|
||||
.on("mouseleave", (event: MouseEvent) => {
|
||||
d3.select(event.currentTarget as SVGCircleElement)
|
||||
.attr("fill-opacity", 0.75)
|
||||
.attr("stroke-width", 1.5);
|
||||
setTooltip(null);
|
||||
})
|
||||
.on("click", (_event: MouseEvent, d) => {
|
||||
onSessionClick?.(d.id);
|
||||
});
|
||||
}, [data, width, height, onSessionClick, t]);
|
||||
|
||||
if (data.length === 0) return <EmptyState />;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full flex flex-col justify-center"
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={height}
|
||||
aria-label={t("complexity.ariaLabel")}
|
||||
role="img"
|
||||
/>
|
||||
<Legend />
|
||||
{tooltip && <Tooltip state={tooltip} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
/**
|
||||
* @file SubagentEffectiveness.tsx
|
||||
* @description Defines the SubagentEffectiveness React component that visualizes the effectiveness of subagents in a workflow. It displays a success rate as a circular progress ring, key metrics such as total sessions and average duration, and a sparkline showing weekly activity trends. The component is designed to handle cases with no data gracefully and uses a consistent color scheme for clarity.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SubagentEffectivenessProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SubagentEffectiveness` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SubagentEffectivenessProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SubagentEffectiveness**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SubagentEffectivenessItem } from "../../lib/types";
|
||||
|
||||
const COLORS = [
|
||||
"#10b981",
|
||||
"#3b82f6",
|
||||
"#a855f7",
|
||||
"#f59e0b",
|
||||
"#f43f5e",
|
||||
"#06b6d4",
|
||||
"#f97316",
|
||||
"#6366f1",
|
||||
] as const;
|
||||
|
||||
const RING_RADIUS = 28;
|
||||
const RING_STROKE = 5;
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
|
||||
|
||||
function formatDurationSec(seconds: number | null): string {
|
||||
if (seconds === null || seconds < 0) return "-";
|
||||
const totalSec = Math.floor(seconds);
|
||||
const hours = Math.floor(totalSec / 3600);
|
||||
const minutes = Math.floor((totalSec % 3600) / 60);
|
||||
const secs = totalSec % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
interface SuccessRingProps {
|
||||
rate: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function SuccessRing({ rate, color }: SuccessRingProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const clampedRate = Math.max(0, Math.min(100, rate));
|
||||
const filled = (clampedRate / 100) * RING_CIRCUMFERENCE;
|
||||
const gap = RING_CIRCUMFERENCE - filled;
|
||||
const viewSize = (RING_RADIUS + RING_STROKE) * 2 + 4;
|
||||
const center = viewSize / 2;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg
|
||||
width={viewSize}
|
||||
height={viewSize}
|
||||
viewBox={`0 0 ${viewSize} ${viewSize}`}
|
||||
aria-label={t("effectiveness.successRateAria", { rate: clampedRate.toFixed(1) })}
|
||||
role="img"
|
||||
>
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke="#2a2a3d"
|
||||
strokeWidth={RING_STROKE}
|
||||
/>
|
||||
{/* Arc */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={RING_STROKE}
|
||||
strokeDasharray={`${filled} ${gap}`}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
style={{ transition: "stroke-dasharray 0.6s ease" }}
|
||||
/>
|
||||
{/* Percentage label */}
|
||||
<text
|
||||
x={center}
|
||||
y={center}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="#e4e4ed"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
fontFamily="Inter, sans-serif"
|
||||
>
|
||||
{clampedRate.toFixed(0)}%
|
||||
</text>
|
||||
</svg>
|
||||
<span className="text-[10px] font-medium text-fg-muted uppercase tracking-wider">
|
||||
{t("effectiveness.success")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface SparklineTooltipState {
|
||||
index: number;
|
||||
/** Bounding rect of the hovered bar (in viewport coordinates). */
|
||||
rect: DOMRect;
|
||||
}
|
||||
|
||||
function Sparkline({ data, color }: SparklineProps) {
|
||||
const { t, i18n } = useTranslation(["workflows", "common"]);
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language;
|
||||
const dayLabels = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 7 }, (_, day) =>
|
||||
new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
|
||||
new Date(Date.UTC(2026, 0, 5 + day))
|
||||
)
|
||||
),
|
||||
[locale]
|
||||
);
|
||||
const [tip, setTip] = useState<SparklineTooltipState | null>(null);
|
||||
const bars = data.length > 0 ? data : Array.from({ length: 7 }, () => 0);
|
||||
const max = Math.max(...bars, 1);
|
||||
|
||||
return (
|
||||
<div aria-label={t("effectiveness.weeklyActivityAria")}>
|
||||
{/* Bars */}
|
||||
<div className="flex items-end gap-1 h-8 relative" onMouseLeave={() => setTip(null)}>
|
||||
{bars.map((value, i) => {
|
||||
const heightPct = Math.max((value / max) * 100, value > 0 ? 8 : 4);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 relative"
|
||||
style={{ height: "100%" }}
|
||||
onMouseEnter={(e) =>
|
||||
setTip({ index: i, rect: e.currentTarget.getBoundingClientRect() })
|
||||
}
|
||||
>
|
||||
{/* Bar (anchored to bottom) */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 rounded-sm transition-all duration-300"
|
||||
style={{
|
||||
height: `${heightPct}%`,
|
||||
backgroundColor: value > 0 ? color : "#2a2a3d",
|
||||
opacity: tip?.index === i ? 1 : value > 0 ? 0.85 : 0.4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Day labels */}
|
||||
<div className="flex gap-1 mt-1">
|
||||
{bars.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex-1 text-center text-[8px] text-fg-muted leading-none select-none"
|
||||
>
|
||||
{dayLabels[i % dayLabels.length] ?? ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{tip && (
|
||||
<SparklineTooltip
|
||||
rect={tip.rect}
|
||||
label={dayLabels[tip.index % dayLabels.length] ?? ""}
|
||||
value={bars[tip.index] ?? 0}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip is rendered into `document.body` via a portal so the parent
|
||||
* ScoreCard's `overflow-hidden` (and hover-transform that would otherwise
|
||||
* become its containing block) cannot clip it. Coordinates are computed
|
||||
* from the hovered bar's bounding rect and clamped to the viewport with an
|
||||
* 8 px margin, so the tooltip can never be cut off on any day of the week.
|
||||
*/
|
||||
function SparklineTooltip({
|
||||
rect,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
rect: DOMRect;
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const w = el.offsetWidth;
|
||||
const h = el.offsetHeight;
|
||||
const margin = 8;
|
||||
|
||||
// Center horizontally over the bar, then clamp to viewport.
|
||||
let left = rect.left + rect.width / 2 - w / 2;
|
||||
if (left < margin) left = margin;
|
||||
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
|
||||
|
||||
// Default above the bar; flip below if there isn't room.
|
||||
let top = rect.top - h - 8;
|
||||
if (top < margin) top = rect.bottom + 8;
|
||||
|
||||
setPos({ left, top });
|
||||
}, [rect]);
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="tooltip"
|
||||
className="fixed z-[60] px-2 py-1 bg-[#12121f] border border-[#2a2a4a] rounded-md shadow-xl text-[10px] text-fg-secondary whitespace-nowrap pointer-events-none"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
>
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="text-fg-secondary mx-1">·</span>
|
||||
<span className="tabular-nums" style={{ color }}>
|
||||
{t("effectiveness.sessionCount", { count: value })}
|
||||
</span>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricBoxProps {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function MetricBox({ label, value }: MetricBoxProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5 bg-surface-3 rounded-lg px-2 py-2 flex-1 min-w-0 overflow-hidden">
|
||||
<span className="text-xs font-semibold text-fg-secondary tabular-nums truncate w-full text-center">
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[9px] text-fg-muted uppercase tracking-wider truncate w-full text-center">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScoreCardProps {
|
||||
item: SubagentEffectivenessItem;
|
||||
colorIndex: number;
|
||||
}
|
||||
|
||||
function ScoreCard({ item, colorIndex }: ScoreCardProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const color = COLORS[colorIndex % COLORS.length] ?? COLORS[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
bg-surface-2 border border-border rounded-xl p-4
|
||||
flex flex-col gap-4 min-w-0 overflow-hidden
|
||||
transition-all duration-200
|
||||
hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/30 hover:border-border-light
|
||||
"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm font-medium text-fg-secondary truncate" title={item.subagent_type}>
|
||||
{item.subagent_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Success ring */}
|
||||
<div className="flex justify-center">
|
||||
<SuccessRing rate={item.successRate} color={color} />
|
||||
</div>
|
||||
|
||||
{/* Metric boxes */}
|
||||
<div className="flex gap-2">
|
||||
<MetricBox label={t("effectiveness.sessions")} value={String(item.sessions)} />
|
||||
<MetricBox
|
||||
label={t("effectiveness.avgDuration")}
|
||||
value={formatDurationSec(item.avgDuration)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sparkline */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] text-fg-muted uppercase tracking-wider">
|
||||
{t("effectiveness.weeklyActivity")}
|
||||
</span>
|
||||
<Sparkline data={item.trend} color={color} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SubagentEffectivenessProps {
|
||||
data: SubagentEffectivenessItem[];
|
||||
}
|
||||
|
||||
export function SubagentEffectiveness({ data }: SubagentEffectivenessProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-fg-muted text-sm">
|
||||
{t("effectiveness.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.map((item, i) => (
|
||||
<ScoreCard key={item.subagent_type} item={item} colorIndex={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
/**
|
||||
* @file WorkflowPatterns.tsx
|
||||
* @description Defines the WorkflowPatterns React component that visualizes common workflow patterns detected from session data. It displays a ranked list of patterns based on their frequency, showing the sequence of agent steps in each pattern along with an icon representing the type of workflow. The component also handles cases where no patterns are detected and includes a special item for solo sessions without subagents. Users can click on a pattern to trigger a callback with the pattern's steps for further analysis or filtering.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `WorkflowPatterns` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **WorkflowPatterns**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, Zap, Code2, Shield, Bug, FileText, Lightbulb, Info } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { WorkflowPattern, WorkflowPatternsData } from "../../lib/types";
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_VISIBLE_STEPS = 4;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function patternIcon(steps: string[]): LucideIcon {
|
||||
const joined = steps.join(" ").toLowerCase();
|
||||
if (joined.includes("debug")) return Bug;
|
||||
if (joined.includes("security") || joined.includes("audit")) return Shield;
|
||||
if (joined.includes("code-review") || joined.includes("review")) return Code2;
|
||||
if (joined.includes("doc") || joined.includes("text")) return FileText;
|
||||
return Zap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first agent that appears more than once in the sequence (loop indicator).
|
||||
* Returns null if every step is unique.
|
||||
*/
|
||||
function findRepeatedStep(steps: string[]): string | null {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i];
|
||||
if (s !== undefined && steps.indexOf(s) !== i) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic, value-dependent narrative for a workflow pattern.
|
||||
* Pure rule-based mapping - same input always yields the same output, so the
|
||||
* UI never produces hallucinated descriptions for ambiguous patterns.
|
||||
*/
|
||||
function describePattern(pattern: WorkflowPattern, t: TFn): string {
|
||||
const { steps, percentage } = pattern;
|
||||
if (steps.length === 0) return "";
|
||||
|
||||
const first = steps[0] ?? "";
|
||||
const last = steps[steps.length - 1] ?? first;
|
||||
const repeated = findRepeatedStep(steps);
|
||||
|
||||
// Shape of the chain
|
||||
let core: string;
|
||||
if (steps.length === 1) {
|
||||
core = t("patterns.detail.narrative.soloFmt", { first });
|
||||
} else if (steps.length === 2) {
|
||||
core = t("patterns.detail.narrative.twoStepFmt", { first, last });
|
||||
} else if (steps.length <= 5) {
|
||||
core = t("patterns.detail.narrative.shortFmt", { count: steps.length, first, last });
|
||||
} else {
|
||||
core = t("patterns.detail.narrative.longFmt", { count: steps.length, first, last });
|
||||
}
|
||||
|
||||
if (repeated) {
|
||||
core += t("patterns.detail.narrative.loopHintFmt", { agent: repeated });
|
||||
}
|
||||
|
||||
// Frequency bucket
|
||||
let freq: string;
|
||||
if (percentage > 50) freq = t("patterns.detail.narrative.dominant");
|
||||
else if (percentage > 25) freq = t("patterns.detail.narrative.common");
|
||||
else if (percentage > 10) freq = t("patterns.detail.narrative.regular");
|
||||
else freq = t("patterns.detail.narrative.niche");
|
||||
|
||||
return core + freq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a suggestion bucket based on chain length and whether a loop exists.
|
||||
* Loop wins over length so the user is reminded to confirm intentional loops.
|
||||
*/
|
||||
function suggestionForPattern(pattern: WorkflowPattern, t: TFn): string {
|
||||
const { steps } = pattern;
|
||||
if (findRepeatedStep(steps)) return t("patterns.detail.suggestion.loop");
|
||||
if (steps.length <= 1) return t("patterns.detail.suggestion.solo");
|
||||
if (steps.length <= 3) return t("patterns.detail.suggestion.shortChain");
|
||||
if (steps.length <= 6) return t("patterns.detail.suggestion.mediumChain");
|
||||
return t("patterns.detail.suggestion.longChain");
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function StepPill({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StepFlow({ steps }: { steps: string[] }) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const visible = steps.slice(0, MAX_VISIBLE_STEPS);
|
||||
const overflow = steps.length - MAX_VISIBLE_STEPS;
|
||||
|
||||
return (
|
||||
<div className="flex items-center flex-wrap gap-1 min-w-0">
|
||||
{visible.map((step, idx) => (
|
||||
<span key={idx} className="flex items-center gap-1">
|
||||
<StepPill label={step} />
|
||||
{(idx < visible.length - 1 || overflow > 0) && (
|
||||
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-fg-muted" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium bg-surface-3/50 text-fg-secondary border border-border-light/20 whitespace-nowrap">
|
||||
{t("common:plusMore", { count: overflow })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternFrequency({ count, percentage }: { count: number; percentage: number }) {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className="text-sm font-semibold text-fg-primary">{count.toLocaleString()}</p>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PatternItemProps {
|
||||
pattern: WorkflowPattern;
|
||||
rank: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function PatternItem({ pattern, rank, isSelected, onClick }: PatternItemProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const Icon = patternIcon(pattern.steps);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"rounded-lg border transition-colors duration-150 overflow-hidden",
|
||||
isSelected
|
||||
? "bg-indigo-500/10 border-indigo-500/30"
|
||||
: "bg-surface-2 border-transparent hover:bg-white/5 hover:border-white/10",
|
||||
].join(" ")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-expanded={isSelected}
|
||||
title={t("patterns.detail.clickHint")}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
{/* Rank / icon */}
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-md bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center">
|
||||
{rank <= 3 ? (
|
||||
<span className="text-xs font-bold text-indigo-400">{rank}</span>
|
||||
) : (
|
||||
<Icon className="w-3.5 h-3.5 text-indigo-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step flow */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<StepFlow steps={pattern.steps} />
|
||||
</div>
|
||||
|
||||
{/* Frequency */}
|
||||
<PatternFrequency count={pattern.count} percentage={pattern.percentage} />
|
||||
|
||||
{/* Click affordance - visible only when not yet expanded so users know the row is interactive. */}
|
||||
{!isSelected && (
|
||||
<Info className="hidden sm:block w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isSelected && <PatternDetail pattern={pattern} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const uniqueAgents = new Set(pattern.steps).size;
|
||||
const narrative = describePattern(pattern, t);
|
||||
const suggestion = suggestionForPattern(pattern, t);
|
||||
|
||||
return (
|
||||
<div className="border-t border-indigo-500/20 bg-surface-1/40 px-4 py-3.5 space-y-3.5">
|
||||
{/* Full step sequence (no truncation) */}
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-fg-muted uppercase tracking-wider mb-2">
|
||||
{t("patterns.detail.stepsHeading")}
|
||||
</p>
|
||||
<div className="flex items-center flex-wrap gap-1.5">
|
||||
{pattern.steps.map((step, i) => (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-indigo-500/15 text-indigo-200 border border-indigo-500/25">
|
||||
<span className="text-indigo-400/70 mr-1.5 text-[10px] font-bold">{i + 1}</span>
|
||||
{step}
|
||||
</span>
|
||||
{i < pattern.steps.length - 1 && (
|
||||
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-fg-muted" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<DetailStat
|
||||
label={t("patterns.detail.stepsCount", { count: pattern.steps.length })}
|
||||
value={String(pattern.steps.length)}
|
||||
/>
|
||||
<DetailStat label={t("patterns.detail.uniqueAgents")} value={String(uniqueAgents)} />
|
||||
<DetailStat
|
||||
label={t("patterns.detail.occurrences")}
|
||||
value={pattern.count.toLocaleString()}
|
||||
/>
|
||||
<DetailStat
|
||||
label={t("patterns.detail.shareOfSessions")}
|
||||
value={`${pattern.percentage.toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Narrative - what this means */}
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-fg-muted uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||
<Info className="w-3 h-3 text-indigo-400" />
|
||||
{t("patterns.detail.narrativeHeading")}
|
||||
</p>
|
||||
<p className="text-xs text-fg-secondary leading-relaxed">{narrative}</p>
|
||||
</div>
|
||||
|
||||
{/* Suggestion */}
|
||||
<div className="bg-indigo-500/5 border border-indigo-500/15 rounded-md px-3 py-2.5">
|
||||
<p className="text-[10px] font-semibold text-indigo-300 uppercase tracking-wider mb-1 flex items-center gap-1.5">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
{t("patterns.detail.suggestionHeading")}
|
||||
</p>
|
||||
<p className="text-xs text-fg-secondary leading-relaxed">{suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-2.5 py-2">
|
||||
<p className="text-sm font-semibold text-fg-primary tabular-nums">{value}</p>
|
||||
<p className="text-[10px] text-fg-muted uppercase tracking-wider mt-0.5 truncate">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SoloSessionItem({ count, percentage }: { count: number; percentage: number }) {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-lg border bg-yellow-500/5 border-yellow-500/20">
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-md bg-yellow-500/10 border border-yellow-500/20 flex items-center justify-center">
|
||||
<Zap className="w-3.5 h-3.5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-yellow-500/15 text-yellow-300 border border-yellow-500/20">
|
||||
{t("patterns.solo")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className="text-sm font-semibold text-fg-primary">{count.toLocaleString()}</p>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPatterns() {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
|
||||
<Zap className="w-5 h-5 text-fg-muted" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-fg-secondary">{t("patterns.noData")}</p>
|
||||
<p className="text-xs text-fg-muted mt-1">{t("patterns.noDataDesc")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public component ──────────────────────────────────────────────────────────
|
||||
|
||||
interface WorkflowPatternsProps {
|
||||
data: WorkflowPatternsData;
|
||||
onPatternClick?: (steps: string[]) => void;
|
||||
}
|
||||
|
||||
export function WorkflowPatterns({ data, onPatternClick }: WorkflowPatternsProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const handlePatternClick = (index: number, steps: string[]) => {
|
||||
const next = selectedIndex === index ? null : index;
|
||||
setSelectedIndex(next);
|
||||
if (next !== null) {
|
||||
onPatternClick?.(steps);
|
||||
}
|
||||
};
|
||||
|
||||
const hasContent = data.patterns.length > 0 || data.soloSessionCount > 0;
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<h2 className="text-sm font-semibold text-fg-secondary uppercase tracking-wider mb-4">
|
||||
{t("patterns.label")}
|
||||
</h2>
|
||||
|
||||
{!hasContent ? (
|
||||
<EmptyPatterns />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.patterns.map((pattern, idx) => (
|
||||
<PatternItem
|
||||
key={idx}
|
||||
pattern={pattern}
|
||||
rank={idx + 1}
|
||||
isSelected={selectedIndex === idx}
|
||||
onClick={() => handlePatternClick(idx, pattern.steps)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{data.soloSessionCount > 0 && (
|
||||
<SoloSessionItem count={data.soloSessionCount} percentage={data.soloPercentage} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @file Tests for the Workflows page's lane pipeline panel: it lists every lane
|
||||
* as a tile, shows the first lane's pipeline before anything is clicked, and
|
||||
* swaps the map when another lane is selected. A wrong selection would show one
|
||||
* lane's stage under another lane's name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { LanePipelinePanel } from "../LanePipelinePanel";
|
||||
|
||||
const { LANES } = vi.hoisted(() => {
|
||||
const laneStub = (id: number, stageId: string) => ({
|
||||
id,
|
||||
title: `lane-${id}`,
|
||||
cwd: `/workspace/lane-${id}`,
|
||||
branch: null,
|
||||
kind: "adopted",
|
||||
source_repo: null,
|
||||
pipeline: "default",
|
||||
session_id: null,
|
||||
run_id: null,
|
||||
stage: stageId,
|
||||
stage_since: null,
|
||||
status: "idle",
|
||||
gate_decision: null,
|
||||
ci_status: null,
|
||||
needs_action: null,
|
||||
links: {},
|
||||
stages: {},
|
||||
notes: null,
|
||||
pipeline_name: "Default",
|
||||
pipeline_nodes: [
|
||||
{
|
||||
id: stageId,
|
||||
label: stageId,
|
||||
icon: "🧭",
|
||||
gate: false,
|
||||
state: "current",
|
||||
},
|
||||
],
|
||||
progress: 25,
|
||||
stage_seconds: null,
|
||||
last_event_seconds: null,
|
||||
liveness: "idle",
|
||||
detected_stage: null,
|
||||
detected_signal: null,
|
||||
slot: null,
|
||||
ports: {},
|
||||
active_feature_id: null,
|
||||
});
|
||||
return { LANES: [laneStub(1, "plan"), laneStub(2, "tests")] };
|
||||
});
|
||||
|
||||
const listMock = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { lanes: { list: () => listMock() } } };
|
||||
});
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<LanePipelinePanel />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe("LanePipelinePanel", () => {
|
||||
it("shows the first lane's pipeline before any tile is clicked", async () => {
|
||||
listMock.mockResolvedValue({
|
||||
lanes: LANES,
|
||||
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
expect(await screen.findByTestId("lane-tile-1")).toHaveAttribute("data-selected", "true");
|
||||
expect(screen.getByTestId("pipeline-node-plan")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pipeline-node-tests")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swaps the pipeline map when another lane is selected", async () => {
|
||||
listMock.mockResolvedValue({
|
||||
lanes: LANES,
|
||||
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(await screen.findByTestId("lane-tile-2"));
|
||||
|
||||
expect(screen.getByTestId("pipeline-node-tests")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pipeline-node-plan")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the CLI hint when no lane exists", async () => {
|
||||
listMock.mockResolvedValue({
|
||||
lanes: [],
|
||||
counts: { total: 0, running: 0, needs_you: 0, dead: 0 },
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
expect(await screen.findByText(/ccam lanes add/)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workflows-lane-strip")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a failed fetch instead of an empty strip", async () => {
|
||||
listMock.mockRejectedValue(new Error("lanes unreachable"));
|
||||
renderPanel();
|
||||
|
||||
expect(await screen.findByText("lanes unreachable")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -63,50 +63,15 @@
|
||||
"howToRead": "Tools appear as colored bars on the left and right. Bands connect them: thickness = number of times the source ran immediately before the target. Hover a band for share-of-source and share-of-target percentages; hover a node for its total usage and share of all tool calls.",
|
||||
"why": "Surfaces habitual tool chains (e.g. Read → Edit → Bash). Useful for spotting loops, wasted hops, and missing tool variety in agent behavior."
|
||||
},
|
||||
"pipeline": {
|
||||
"what": "A directed graph of agent-type co-occurrence: which agent types tend to run sequentially after which, and how often.",
|
||||
"howToRead": "Circles are agent types; size encodes total spawns. Arrows go A → B when B was observed running shortly after A in the same session, with the label = count. Hover an arrow for share of source/target; hover a circle for spawns, sessions, and success rate. Drag nodes to rearrange.",
|
||||
"why": "Surfaces real delegation pipelines you may not have intentionally designed - e.g. a code-review agent that nearly always follows a build agent - so you can codify or simplify them."
|
||||
},
|
||||
"effectiveness": {
|
||||
"what": "One card per subagent type with a success-rate ring, total sessions, average duration, and a 7-day activity sparkline.",
|
||||
"howToRead": "Ring fill is success rate; the percentage in the center is the same. The two boxes give counts and average wall-clock duration. The sparkline is sessions per day for the last week - hover a bar for the daily count.",
|
||||
"why": "Quick health check per agent: which agents are reliable, which are slow, and which are trending up or down in usage."
|
||||
},
|
||||
"patterns": {
|
||||
"what": "A ranked list of recurring agent-orchestration sequences detected from session traces, plus a callout for solo sessions that ran without subagents.",
|
||||
"howToRead": "Each row is a step sequence (main → sub → sub …). The right side shows how many sessions matched it and what percentage of all sessions that represents. Click a pattern to highlight it.",
|
||||
"why": "Reveals the standard 'shapes' of work in your project. Frequent patterns are good candidates to formalize as named workflows or skills."
|
||||
},
|
||||
"modelDelegation": {
|
||||
"what": "A two-column flow showing which main models delegate to which subagent models, plus per-node agent counts and total tokens.",
|
||||
"howToRead": "Left column = main models for sessions; right column = models used inside subagents. Lines indicate observed main → subagent pairings. Hover a node for share-of-all-agents, sessions, total tokens, and a description of the model family (Opus / Sonnet / Haiku).",
|
||||
"why": "Helps you confirm cost/latency tiering - e.g. main on Sonnet, subagents on Haiku - and catch unintended escalations to Opus."
|
||||
},
|
||||
"errorPropagation": {
|
||||
"what": "Where errors cluster across the agent hierarchy: by depth (session/main vs direct subagent vs nested), by agent type, and by API/session-level error category.",
|
||||
"howToRead": "The top banner gives the session-level error rate. The depth bars show how many errors occurred at each nesting level. The error-prone types list ranks subagent types by error count. The bottom block surfaces API/session error summaries.",
|
||||
"why": "Pinpoints whether failures live in the orchestration spine (main agent), in specific subagents, or in deep nested chains - guiding where to invest in retries, validation, or rewrites."
|
||||
},
|
||||
"concurrency": {
|
||||
"what": "Aggregate timing of when each agent type is active relative to the session timeline, plus how many sessions feature it.",
|
||||
"howToRead": "Each row is one agent type. Bar length is proportional to session count. The right-hand percentages show the average start and end position within a session (0% = session start, 100% = session end). Hover for a description of the timing pattern (front-loaded, mid-session, back-loaded, etc.).",
|
||||
"why": "Reveals the rhythm of your sessions - e.g. linting up front, code-review near the end - and surfaces agents that monopolize the entire session."
|
||||
},
|
||||
"complexity": {
|
||||
"what": "A scatter plot of every session: x = wall-clock duration, y = total agents involved, bubble size = total tokens, color = final status.",
|
||||
"howToRead": "Top-right bubbles are long, agent-heavy sessions. Big bubbles burned the most tokens. Reds/yellows = error/abandoned outcomes. Hover any bubble for full session stats; click to drill into it below.",
|
||||
"why": "Gives a single view of the cost/complexity distribution. Outliers are the sessions worth investigating for runaway behavior or cost spikes."
|
||||
},
|
||||
"compaction": {
|
||||
"what": "How often Claude triggered context compaction across sessions: total events, tokens recovered, and a per-session distribution.",
|
||||
"howToRead": "The two stat boxes show total compaction events and the cumulative tokens reclaimed. The bar chart lists sessions with at least one compaction, sorted descending by count.",
|
||||
"why": "Compactions indicate context pressure. A long tail of high-compaction sessions usually means workflows that should be split, summarized, or migrated to a longer-context model."
|
||||
},
|
||||
"drillIn": {
|
||||
"what": "Per-session detail view: full agent tree, tool-call timeline, and raw event sequence for whichever session you select.",
|
||||
"howToRead": "Pick a session from the dropdown above or click a bubble in the scatter plot. The tabs let you switch between hierarchical (Agent Tree), temporal (Tool Timeline), and chronological (Event Sequence) views of the same session.",
|
||||
"why": "When an aggregate chart flags a suspicious session, this is where you go to read exactly what happened, in order."
|
||||
},
|
||||
"lanes": {
|
||||
"what": "The lanes this dashboard tracks — one durable unit of parallel agent work per working directory — and the pipeline stages each one has reached.",
|
||||
"howToRead": "Pick a lane tile to see its pipeline below. A node is green when it is done with evidence, solid blue for the current stage, amber when claimed without evidence, dashed amber when only detected from tool events, red when rejected.",
|
||||
"why": "This is your own delivery pipeline, not Claude Code internals: it answers where each parallel workstream stands right now. Lane actions live on the Workspace page."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
@@ -244,201 +209,6 @@
|
||||
"linkDescFmt": "Across every session, this counts how often {{source}} ran immediately before {{target}}. Thicker bands = more common transitions; the percentages show how dominant this hop is among each tool's outgoing / incoming flow."
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
"title": "Agent Pipeline Graph",
|
||||
"subtitle": "Directed workflow - which agent types run after which, with frequency labels",
|
||||
"noData": "No pipeline data yet",
|
||||
"noDataDesc": "Directed workflow edges appear once agent types run sequentially across multiple sessions.",
|
||||
"legend": "Legend",
|
||||
"legendDesc": "A runs before B (labeled count)",
|
||||
"frequency": "Frequency",
|
||||
"meaning": "Meaning",
|
||||
"times": " times",
|
||||
"ranAfter": " ran after ",
|
||||
"spawns": " spawns",
|
||||
"spawned": "Spawned",
|
||||
"inSessions": "In sessions",
|
||||
"ariaLabel": "Agent pipeline graph",
|
||||
"tooltip": {
|
||||
"agentType": "Agent type",
|
||||
"edge": "Sequential edge",
|
||||
"shareOfAllSpawns": "Share of all spawns",
|
||||
"sequentialPairs": "Sequential pairs",
|
||||
"totalSpawnsFmt": "{{id}} total spawns",
|
||||
"shareOfSrcFmt": "Share of {{source}} → next",
|
||||
"shareOfTgtFmt": "Share of prev → {{target}}",
|
||||
"edgeDescFmt": "{{target}} ran immediately after {{source}} {{count}} time(s) across all sessions. The thicker the arrow, the more often this hand-off occurred. Use this to spot habitual delegation chains in your workflows.",
|
||||
"nodeDescFmt": "{{id}} ran {{total}} time(s) across {{sessions}} session(s); its success rate is {{rate}}% ({{health}}). Circle size encodes total spawns; arrows show which agent types tend to run before or after it.",
|
||||
"health": {
|
||||
"perfect": "near-perfect",
|
||||
"healthy": "healthy",
|
||||
"shaky": "shaky",
|
||||
"failing": "failing often"
|
||||
}
|
||||
}
|
||||
},
|
||||
"effectiveness": {
|
||||
"title": "Subagent Effectiveness",
|
||||
"subtitle": "Performance metrics per agent type",
|
||||
"success": "Success",
|
||||
"sessions": "Sessions",
|
||||
"avgDuration": "Avg Duration",
|
||||
"weeklyActivity": "Weekly activity",
|
||||
"noData": "No subagent data available",
|
||||
"successRateAria": "Success rate: {{rate}}%",
|
||||
"weeklyActivityAria": "Weekly activity sparkline",
|
||||
"sessionCount_one": "{{count}} session",
|
||||
"sessionCount_other": "{{count}} sessions"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Detected Workflow Patterns",
|
||||
"subtitle": "Common agent orchestration sequences",
|
||||
"noData": "No patterns detected yet",
|
||||
"noDataDesc": "Patterns appear once multiple sessions share the same agent sequence.",
|
||||
"solo": "solo (no subagents)",
|
||||
"label": "Workflow Patterns",
|
||||
"detail": {
|
||||
"clickHint": "Click for details",
|
||||
"stepsHeading": "Full step sequence",
|
||||
"statsHeading": "Stats",
|
||||
"stepsCount_one": "{{count}} step",
|
||||
"stepsCount_other": "{{count}} steps",
|
||||
"uniqueAgents": "Unique agent types",
|
||||
"occurrences": "Sessions matching this pattern",
|
||||
"shareOfSessions": "Share of all sessions",
|
||||
"narrativeHeading": "What this means",
|
||||
"suggestionHeading": "Suggestion",
|
||||
"narrative": {
|
||||
"soloFmt": "Sessions that ran with only {{first}} - no delegation to other agent types.",
|
||||
"twoStepFmt": "A two-step hand-off: {{first}} delegates straight to {{last}}, with no further branching.",
|
||||
"shortFmt": "A {{count}}-step chain that starts at {{first}} and ends at {{last}}.",
|
||||
"longFmt": "A long {{count}}-step chain - begins at {{first}}, threads through several agents, and lands at {{last}}.",
|
||||
"loopHintFmt": " {{agent}} appears more than once in this sequence - typical of a verification or revision loop.",
|
||||
"dominant": " This is the dominant workflow in your project - more than half of sessions follow it.",
|
||||
"common": " This is a common workflow, accounting for roughly a quarter of sessions.",
|
||||
"regular": " A recognised workflow, but not the majority pattern.",
|
||||
"niche": " A niche pattern that only surfaces occasionally."
|
||||
},
|
||||
"suggestion": {
|
||||
"solo": "Solo run - would a code-reviewer or test-runner subagent raise the quality of these sessions?",
|
||||
"shortChain": "Short chain. If it keeps recurring, name it as a slash command, skill, or named workflow so it's easy to reproduce.",
|
||||
"mediumChain": "Frequent enough to formalise. A named workflow or template would make this pattern reusable.",
|
||||
"longChain": "Long chain - review whether every hop is necessary, or whether some agents could be merged or skipped.",
|
||||
"loop": "Loop detected. Confirm the repeated step is intentional verification rather than a silent retry of a failing call."
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelDelegation": {
|
||||
"title": "Model Delegation Flow",
|
||||
"subtitle": "How models route through agent hierarchies",
|
||||
"mainModels": "MAIN MODELS",
|
||||
"subagentModels": "SUBAGENT MODELS",
|
||||
"noData": "No model delegation data available",
|
||||
"agentsCount_one": "{{count}} agent",
|
||||
"agentsCount_other": "{{count}} agents",
|
||||
"tokensCount": "{{tokens}} tokens",
|
||||
"sessionsCount_one": "{{count}} session",
|
||||
"sessionsCount_other": "{{count}} sessions",
|
||||
"ariaLabel": "Model delegation flow diagram",
|
||||
"tooltip": {
|
||||
"mainModel": "Main model",
|
||||
"subagentModel": "Subagent model",
|
||||
"agentRuns": "Agent runs",
|
||||
"shareOfAll": "Share of all agents",
|
||||
"sessionsOnModel": "Sessions on this model",
|
||||
"totalTokens": "Total tokens",
|
||||
"family": {
|
||||
"opus": "Opus - highest-capability tier; best for complex reasoning, longest context, costliest per token.",
|
||||
"sonnet": "Sonnet - balanced tier; good default for most coding and tool-use workflows.",
|
||||
"haiku": "Haiku - fastest, cheapest tier; ideal for short, latency-sensitive subagents.",
|
||||
"other": "Non-Anthropic or unrecognized model - falls outside the standard Opus/Sonnet/Haiku families."
|
||||
},
|
||||
"lines": {
|
||||
"main": "Lines on the right show subagent models this main model delegated work to.",
|
||||
"sub": "Lines on the left show main models that delegated work to this subagent model."
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorPropagation": {
|
||||
"title": "Error Propagation Map",
|
||||
"subtitle": "Where errors cluster in agent hierarchy depth",
|
||||
"noErrors": "No errors recorded",
|
||||
"allSuccess": "All sessions completed successfully",
|
||||
"sessionsHadErrors": " sessions had errors",
|
||||
"sessionsErrorSummary": "{{errorSessions}} of {{totalSessions}} sessions had errors",
|
||||
"agentErrors": " agent-level error(s) across hierarchy",
|
||||
"sessionErrorsOnly": "Session-level errors only",
|
||||
"errorsByDepth": "Errors by hierarchy depth",
|
||||
"errorProneTypes": "Error-prone agent types",
|
||||
"apiSessionErrors": "API & session errors",
|
||||
"depthLabels": {
|
||||
"sessionMain": "Session / Main",
|
||||
"directSubagent": "Direct Subagent",
|
||||
"nested": "Nested (depth 2)",
|
||||
"deep": "Deep (depth 3+)"
|
||||
}
|
||||
},
|
||||
"concurrency": {
|
||||
"title": "Agent Concurrency Timeline",
|
||||
"subtitle": "Parallel agent execution patterns - Shows how agents overlap in time",
|
||||
"noData": "No concurrency data",
|
||||
"noDataDesc": "Concurrency data appears once sessions with subagents have been recorded.",
|
||||
"sessions": "Sessions",
|
||||
"timing": "Timing",
|
||||
"max": " max",
|
||||
"sessionInfo_one": "{{count}} session - active {{from}}%-{{to}}% of session",
|
||||
"sessionInfo_other": "{{count}} sessions - active {{from}}%-{{to}}% of session",
|
||||
"tooltip": {
|
||||
"lane": "Concurrency lane",
|
||||
"sessionsWith": "Sessions with this agent",
|
||||
"avgStart": "Avg start in session",
|
||||
"avgEnd": "Avg end in session",
|
||||
"barHint": "Bar length is proportional to how many sessions feature this agent type. Timing percentages are averages across those sessions.",
|
||||
"timing": {
|
||||
"wholeSession": "Active across nearly the entire session - runs from the start through to the end.",
|
||||
"frontLoadedFmt": "Front-loaded: typically active from session start until ~{{end}}% of the way through.",
|
||||
"backLoadedFmt": "Back-loaded: typically kicks in around {{start}}% of the session and stays until the end.",
|
||||
"tightFmt": "Tightly scoped: runs in a narrow band between {{start}}% and {{end}}% of the session timeline.",
|
||||
"midSessionFmt": "Mid-session work: runs roughly from {{start}}% to {{end}}% of the session timeline."
|
||||
}
|
||||
}
|
||||
},
|
||||
"complexity": {
|
||||
"title": "Session Complexity Scatter",
|
||||
"subtitle": "Duration vs agent count vs tokens - Bubble size = token usage",
|
||||
"noData": "No session complexity data",
|
||||
"noDataDesc": "Sessions will appear here once they complete with agent activity.",
|
||||
"duration": "Duration",
|
||||
"agentCount": "Agent Count",
|
||||
"ariaLabel": "Session complexity scatter plot",
|
||||
"tooltip": {
|
||||
"duration": "Duration:",
|
||||
"agents": "Agents:",
|
||||
"subagents": "Subagents:",
|
||||
"tokens": "Tokens:",
|
||||
"model": "Model:"
|
||||
}
|
||||
},
|
||||
"compaction": {
|
||||
"title": "Compaction Impact Analysis",
|
||||
"subtitle": "How context compression is spread across your sessions",
|
||||
"help": "When a session's context window fills up, Claude Code compresses older history into a summary. Each compaction is one such event.",
|
||||
"noData": "No compactions recorded",
|
||||
"totalCompactions": "Total compactions",
|
||||
"sessionsAffected": "Sessions affected",
|
||||
"ofTotal": "of {{total}}",
|
||||
"avgPerSession": "Avg / affected session",
|
||||
"peakSession": "Most in one session",
|
||||
"tokensRecovered": "Tokens Recovered",
|
||||
"distribution": "Sessions by compaction count",
|
||||
"xAxis": "Compactions per session",
|
||||
"yAxis": "Sessions",
|
||||
"summary": "{{affected}} of {{total}} sessions ({{pct}}%) used context compaction.",
|
||||
"tokensFreed": "Compaction freed ~{{tokens}} tokens of context.",
|
||||
"ariaLabel": "Number of sessions by compaction count",
|
||||
"tipTitle": "Compacted {{count}}x",
|
||||
"tipDetail": "{{sessions}} sessions · {{pct}}% of affected"
|
||||
},
|
||||
"drillIn": {
|
||||
"title": "Session Drill-In",
|
||||
"subtitle": "Select a session from the scatter plot or use the dropdown to explore",
|
||||
@@ -464,5 +234,10 @@
|
||||
"showingOf": "Showing 100 of {{total}} events",
|
||||
"sessionInfo": " session(s) - active {{from}}%-{{to}}% of session",
|
||||
"sortedByCompaction": " session(s) (sorted by compaction count)"
|
||||
},
|
||||
"lanes": {
|
||||
"title": "Lane Pipelines",
|
||||
"subtitle": "Your own lanes and the stage each one has reached",
|
||||
"openWorkspace": "Open in Workspace"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,50 +63,15 @@
|
||||
"howToRead": "Tool xuất hiện thành các thanh màu ở hai bên. Băng nối chúng: độ dày = số lần nguồn chạy ngay trước đích. Hover một băng để xem tỷ trọng-trong-nguồn và tỷ trọng-trong-đích; hover một node để xem tổng lượt dùng và tỷ trọng trên tổng tool.",
|
||||
"why": "Phơi bày những chuỗi tool quen thuộc (ví dụ Read → Edit → Bash). Hữu ích để phát hiện vòng lặp, bước thừa, và sự đơn điệu trong cách Agent dùng tool."
|
||||
},
|
||||
"pipeline": {
|
||||
"what": "Đồ thị có hướng về sự đồng xuất hiện của loại Agent: loại nào hay chạy ngay sau loại nào, và bao nhiêu lần.",
|
||||
"howToRead": "Hình tròn là loại Agent; kích thước mã hóa tổng số spawn. Mũi tên A → B nghĩa là B đã chạy ngay sau A trong cùng phiên, nhãn = số lần. Hover mũi tên để xem tỷ trọng nguồn/đích; hover hình tròn để xem số spawn, số phiên và tỷ lệ thành công. Có thể kéo các node để sắp xếp lại.",
|
||||
"why": "Phơi bày các pipeline ủy quyền có thực mà bạn có thể chưa cố ý thiết kế - ví dụ một code-review agent gần như luôn chạy sau build agent - để bạn chuẩn hóa hoặc đơn giản hóa."
|
||||
},
|
||||
"effectiveness": {
|
||||
"what": "Mỗi loại Subagent có một thẻ với vòng tròn tỷ lệ thành công, tổng số phiên, thời lượng trung bình và sparkline 7 ngày.",
|
||||
"howToRead": "Phần tô của vòng tròn là tỷ lệ thành công; số phần trăm ở giữa cũng vậy. Hai ô cho biết số lượng và thời gian thực trung bình. Sparkline là số phiên mỗi ngày của tuần qua - hover một thanh để xem số liệu ngày đó.",
|
||||
"why": "Kiểm tra sức khỏe nhanh theo Agent: Agent nào đáng tin, Agent nào chậm, và Agent nào đang tăng/giảm sử dụng."
|
||||
},
|
||||
"patterns": {
|
||||
"what": "Danh sách xếp hạng các chuỗi điều phối Agent lặp lại được phát hiện từ trace phiên, kèm thẻ riêng cho phiên đơn lẻ không có Subagent.",
|
||||
"howToRead": "Mỗi dòng là một chuỗi bước (main → sub → sub …). Bên phải hiển thị số phiên khớp và tỷ lệ trên tổng phiên. Bấm vào một mẫu để làm nổi bật.",
|
||||
"why": "Cho thấy các 'hình dáng' công việc tiêu chuẩn trong dự án. Mẫu phổ biến là ứng viên tốt để chuẩn hóa thành workflow hoặc skill có tên gọi."
|
||||
},
|
||||
"modelDelegation": {
|
||||
"what": "Sơ đồ luồng hai cột cho thấy mô hình chính nào ủy quyền cho mô hình Subagent nào, kèm số Agent và tổng token theo node.",
|
||||
"howToRead": "Cột trái = mô hình chính của phiên; cột phải = mô hình dùng bên trong Subagent. Đường nối chỉ các cặp main → Subagent quan sát được. Hover một node để xem tỷ trọng trên tổng Agent, số phiên, tổng token và mô tả họ mô hình (Opus / Sonnet / Haiku).",
|
||||
"why": "Giúp xác nhận phân tầng chi phí/độ trễ - ví dụ main trên Sonnet, Subagent trên Haiku - và phát hiện các trường hợp bị đẩy lên Opus ngoài ý muốn."
|
||||
},
|
||||
"errorPropagation": {
|
||||
"what": "Lỗi tập trung ở đâu trong phân cấp Agent: theo độ sâu (phiên/main vs Subagent trực tiếp vs lồng nhau), theo loại Agent, và theo nhóm lỗi API / phiên.",
|
||||
"howToRead": "Banner trên đầu cho biết tỷ lệ lỗi cấp phiên. Các thanh độ sâu cho biết bao nhiêu lỗi xảy ra ở từng cấp lồng. Danh sách 'loại dễ lỗi' xếp Subagent theo số lỗi. Khối dưới hiển thị tóm tắt lỗi API/phiên.",
|
||||
"why": "Xác định lỗi nằm ở trục điều phối (main agent), ở Subagent cụ thể, hay ở chuỗi lồng sâu - giúp định hướng nơi nên đầu tư retry, kiểm chứng hay viết lại."
|
||||
},
|
||||
"concurrency": {
|
||||
"what": "Thời điểm tổng hợp mà mỗi loại Agent hoạt động so với timeline phiên, kèm số phiên có Agent đó.",
|
||||
"howToRead": "Mỗi dòng là một loại Agent. Chiều dài thanh tỉ lệ với số phiên. Phần trăm bên phải cho biết vị trí bắt đầu và kết thúc trung bình trong một phiên (0% = đầu phiên, 100% = cuối phiên). Hover để xem mô tả mẫu thời gian (đầu phiên, giữa, cuối, ...).",
|
||||
"why": "Cho thấy nhịp của phiên - ví dụ lint ở đầu, code-review ở cuối - và phát hiện Agent chiếm trọn cả phiên."
|
||||
},
|
||||
"complexity": {
|
||||
"what": "Biểu đồ phân tán mỗi phiên: x = thời lượng thực, y = tổng số Agent, kích thước bong bóng = tổng token, màu = trạng thái cuối.",
|
||||
"howToRead": "Bong bóng phía trên-bên phải là phiên dài và nặng Agent. Bong bóng to dùng nhiều token nhất. Đỏ/vàng = kết quả lỗi/bỏ dở. Hover để xem đầy đủ thống kê phiên; bấm để xem chi tiết bên dưới.",
|
||||
"why": "Cho cái nhìn tổng thể về phân bố chi phí/độ phức tạp. Outlier chính là những phiên đáng điều tra vì hành vi bất thường hoặc đột biến chi phí."
|
||||
},
|
||||
"compaction": {
|
||||
"what": "Tần suất Claude kích hoạt nén ngữ cảnh: tổng số sự kiện, token thu hồi và phân bố theo từng phiên.",
|
||||
"howToRead": "Hai ô số liệu cho biết tổng số lần nén và tổng token đã thu hồi. Biểu đồ cột liệt kê các phiên có ít nhất một lần nén, sắp xếp giảm dần theo số lần.",
|
||||
"why": "Nén ngữ cảnh là dấu hiệu áp lực bộ nhớ. Nhiều phiên có số lần nén cao thường có nghĩa workflow nên được chia nhỏ, tóm tắt, hoặc chuyển sang model có ngữ cảnh dài hơn."
|
||||
},
|
||||
"drillIn": {
|
||||
"what": "Khung chi tiết theo phiên: cây Agent đầy đủ, dòng thời gian tool gọi và chuỗi sự kiện thô của phiên bạn chọn.",
|
||||
"howToRead": "Chọn phiên từ dropdown phía trên hoặc bấm một bong bóng trong biểu đồ phân tán. Các tab cho phép chuyển giữa góc nhìn phân cấp (Agent Tree), thời gian (Tool Timeline) và theo trình tự (Event Sequence) của cùng một phiên.",
|
||||
"why": "Khi biểu đồ tổng hợp đánh dấu một phiên đáng ngờ, đây là nơi bạn đọc chính xác chuyện gì đã xảy ra, theo đúng thứ tự."
|
||||
},
|
||||
"lanes": {
|
||||
"what": "Các lane mà dashboard theo dõi — mỗi thư mục làm việc là một đơn vị công việc song song bền vững — cùng giai đoạn pipeline mỗi lane đã đạt tới.",
|
||||
"howToRead": "Chọn một thẻ lane để xem pipeline bên dưới. Node xanh lá là đã xong kèm bằng chứng, xanh dương đặc là giai đoạn hiện tại, vàng là khai báo nhưng không có bằng chứng, vàng nét đứt là chỉ suy ra từ tool event, đỏ là bị từ chối.",
|
||||
"why": "Đây là pipeline giao việc của chính bạn, không phải nội bộ Claude Code: nó trả lời từng luồng việc song song đang ở đâu. Các thao tác trên lane nằm ở trang Workspace."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
@@ -244,201 +209,6 @@
|
||||
"linkDescFmt": "Trên mọi phiên, đây là số lần {{source}} chạy ngay trước {{target}}. Băng càng dày = lượt chuyển càng phổ biến; phần trăm cho biết bước này chiếm tỷ trọng bao nhiêu trong luồng ra/vào của mỗi tool."
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
"title": "Đồ thị pipeline Agent",
|
||||
"subtitle": "Quy trình có hướng - loại Agent nào chạy sau loại nào, kèm tần suất",
|
||||
"noData": "Chưa có dữ liệu pipeline",
|
||||
"noDataDesc": "Các cạnh quy trình sẽ xuất hiện khi loại Agent chạy tuần tự qua nhiều phiên.",
|
||||
"legend": "Chú giải",
|
||||
"legendDesc": "A chạy trước B (kèm số lần)",
|
||||
"frequency": "Tần suất",
|
||||
"meaning": "Ý nghĩa",
|
||||
"times": " lần",
|
||||
"ranAfter": " chạy sau ",
|
||||
"spawns": " lần spawn",
|
||||
"spawned": "Đã spawn",
|
||||
"inSessions": "Trong các phiên",
|
||||
"ariaLabel": "Đồ thị pipeline Agent",
|
||||
"tooltip": {
|
||||
"agentType": "Loại Agent",
|
||||
"edge": "Cạnh tuần tự",
|
||||
"shareOfAllSpawns": "Tỷ trọng trên tổng spawn",
|
||||
"sequentialPairs": "Cặp tuần tự",
|
||||
"totalSpawnsFmt": "Tổng spawn {{id}}",
|
||||
"shareOfSrcFmt": "Tỷ trọng {{source}} → kế tiếp",
|
||||
"shareOfTgtFmt": "Tỷ trọng trước → {{target}}",
|
||||
"edgeDescFmt": "{{target}} chạy ngay sau {{source}} {{count}} lần trên toàn bộ phiên. Mũi tên càng dày, hand-off càng thường xuyên. Dùng để phát hiện chuỗi ủy quyền quen thuộc trong quy trình của bạn.",
|
||||
"nodeDescFmt": "{{id}} đã chạy {{total}} lần trên {{sessions}} phiên; tỷ lệ thành công {{rate}}% ({{health}}). Kích thước hình tròn mã hóa tổng số spawn; mũi tên cho biết loại Agent nào hay chạy trước/sau nó.",
|
||||
"health": {
|
||||
"perfect": "gần như hoàn hảo",
|
||||
"healthy": "khỏe mạnh",
|
||||
"shaky": "hơi lung lay",
|
||||
"failing": "thường xuyên thất bại"
|
||||
}
|
||||
}
|
||||
},
|
||||
"effectiveness": {
|
||||
"title": "Hiệu quả Subagent",
|
||||
"subtitle": "Chỉ số hiệu năng theo từng loại Agent",
|
||||
"success": "Thành công",
|
||||
"sessions": "Phiên",
|
||||
"avgDuration": "Thời lượng TB",
|
||||
"weeklyActivity": "Hoạt động theo tuần",
|
||||
"noData": "Chưa có dữ liệu Subagent",
|
||||
"successRateAria": "Tỷ lệ thành công: {{rate}}%",
|
||||
"weeklyActivityAria": "Biểu đồ tia hoạt động theo tuần",
|
||||
"sessionCount_one": "{{count}} phiên",
|
||||
"sessionCount_other": "{{count}} phiên"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Mẫu quy trình phát hiện được",
|
||||
"subtitle": "Chuỗi điều phối Agent thường gặp",
|
||||
"noData": "Chưa phát hiện mẫu nào",
|
||||
"noDataDesc": "Mẫu sẽ xuất hiện khi nhiều phiên có cùng chuỗi Agent.",
|
||||
"solo": "đơn lẻ (không có Subagent)",
|
||||
"label": "Mẫu quy trình",
|
||||
"detail": {
|
||||
"clickHint": "Bấm để xem chi tiết",
|
||||
"stepsHeading": "Chuỗi bước đầy đủ",
|
||||
"statsHeading": "Thống kê",
|
||||
"stepsCount_one": "{{count}} bước",
|
||||
"stepsCount_other": "{{count}} bước",
|
||||
"uniqueAgents": "Số loại Agent khác nhau",
|
||||
"occurrences": "Số phiên khớp mẫu này",
|
||||
"shareOfSessions": "Tỷ trọng trên tổng phiên",
|
||||
"narrativeHeading": "Ý nghĩa của mẫu này",
|
||||
"suggestionHeading": "Gợi ý",
|
||||
"narrative": {
|
||||
"soloFmt": "Phiên chỉ chạy {{first}} - không ủy quyền cho loại Agent nào khác.",
|
||||
"twoStepFmt": "Hand-off hai bước: {{first}} ủy quyền thẳng cho {{last}}, không có nhánh nào khác.",
|
||||
"shortFmt": "Chuỗi {{count}} bước, bắt đầu ở {{first}} và kết thúc ở {{last}}.",
|
||||
"longFmt": "Chuỗi dài {{count}} bước - bắt đầu ở {{first}}, đi qua nhiều Agent, rồi dừng ở {{last}}.",
|
||||
"loopHintFmt": " {{agent}} xuất hiện nhiều lần trong chuỗi này - điển hình cho vòng lặp kiểm tra hoặc sửa lại.",
|
||||
"dominant": " Đây là quy trình chủ đạo trong dự án - hơn một nửa số phiên đi theo.",
|
||||
"common": " Là quy trình phổ biến, chiếm khoảng một phần tư số phiên.",
|
||||
"regular": " Là quy trình được nhận diện, nhưng chưa phải đa số.",
|
||||
"niche": " Là mẫu hiếm, chỉ thi thoảng mới xuất hiện."
|
||||
},
|
||||
"suggestion": {
|
||||
"solo": "Phiên đơn lẻ - liệu thêm code-reviewer hay test-runner Subagent có cải thiện chất lượng không?",
|
||||
"shortChain": "Chuỗi ngắn. Nếu lặp lại nhiều, hãy đặt tên thành slash command, skill hoặc workflow để dễ tái sử dụng.",
|
||||
"mediumChain": "Đủ phổ biến để chuẩn hóa. Một workflow/template có tên sẽ giúp tái sử dụng dễ dàng.",
|
||||
"longChain": "Chuỗi dài - xem xét liệu mọi bước có thực sự cần thiết, hay có thể gộp/bỏ bớt một số Agent.",
|
||||
"loop": "Phát hiện vòng lặp. Hãy xác nhận bước lặp là kiểm tra có chủ đích, không phải retry âm thầm một lệnh đang lỗi."
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelDelegation": {
|
||||
"title": "Luồng ủy quyền mô hình",
|
||||
"subtitle": "Cách các mô hình đi qua phân cấp Agent",
|
||||
"mainModels": "MÔ HÌNH CHÍNH",
|
||||
"subagentModels": "MÔ HÌNH TÁC TỬ PHỤ",
|
||||
"noData": "Chưa có dữ liệu ủy quyền mô hình",
|
||||
"agentsCount_one": "{{count}} Agent",
|
||||
"agentsCount_other": "{{count}} Agent",
|
||||
"tokensCount": "{{tokens}} token",
|
||||
"sessionsCount_one": "{{count}} phiên",
|
||||
"sessionsCount_other": "{{count}} phiên",
|
||||
"ariaLabel": "Sơ đồ luồng ủy quyền mô hình",
|
||||
"tooltip": {
|
||||
"mainModel": "Mô hình chính",
|
||||
"subagentModel": "Mô hình Subagent",
|
||||
"agentRuns": "Lượt chạy Agent",
|
||||
"shareOfAll": "Tỷ trọng trên tổng Agent",
|
||||
"sessionsOnModel": "Phiên dùng mô hình này",
|
||||
"totalTokens": "Tổng token",
|
||||
"family": {
|
||||
"opus": "Opus - bậc khả năng cao nhất; tốt cho lý luận phức tạp, ngữ cảnh dài nhất, đắt nhất theo token.",
|
||||
"sonnet": "Sonnet - bậc cân bằng; mặc định tốt cho hầu hết quy trình code và dùng tool.",
|
||||
"haiku": "Haiku - bậc nhanh nhất, rẻ nhất; phù hợp cho Subagent ngắn, nhạy cảm độ trễ.",
|
||||
"other": "Mô hình ngoài Anthropic hoặc không nhận dạng được - không thuộc bộ Opus/Sonnet/Haiku tiêu chuẩn."
|
||||
},
|
||||
"lines": {
|
||||
"main": "Các đường bên phải cho thấy mô hình Subagent mà mô hình chính này đã ủy quyền.",
|
||||
"sub": "Các đường bên trái cho thấy mô hình chính nào đã ủy quyền cho mô hình Subagent này."
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorPropagation": {
|
||||
"title": "Bản đồ lan truyền lỗi",
|
||||
"subtitle": "Điểm tập trung lỗi theo độ sâu phân cấp Agent",
|
||||
"noErrors": "Không ghi nhận lỗi",
|
||||
"allSuccess": "Tất cả phiên đã hoàn tất thành công",
|
||||
"sessionsHadErrors": " phiên gặp lỗi",
|
||||
"sessionsErrorSummary": "{{errorSessions}} trên {{totalSessions}} phiên gặp lỗi",
|
||||
"agentErrors": " lỗi cấp Agent trong toàn bộ phân cấp",
|
||||
"sessionErrorsOnly": "Chỉ có lỗi ở cấp phiên",
|
||||
"errorsByDepth": "Lỗi theo độ sâu phân cấp",
|
||||
"errorProneTypes": "Loại Agent dễ lỗi",
|
||||
"apiSessionErrors": "Lỗi API & phiên",
|
||||
"depthLabels": {
|
||||
"sessionMain": "Phiên / Agent chính",
|
||||
"directSubagent": "Subagent trực tiếp",
|
||||
"nested": "Lồng nhau (độ sâu 2)",
|
||||
"deep": "Sâu (độ sâu 3+)"
|
||||
}
|
||||
},
|
||||
"concurrency": {
|
||||
"title": "Dòng thời gian đồng thời Agent",
|
||||
"subtitle": "Mẫu thực thi song song - thể hiện mức chồng lấp theo thời gian",
|
||||
"noData": "Chưa có dữ liệu đồng thời",
|
||||
"noDataDesc": "Dữ liệu đồng thời sẽ xuất hiện khi đã ghi nhận phiên có Subagent.",
|
||||
"sessions": "Phiên",
|
||||
"timing": "Thời gian",
|
||||
"max": " tối đa",
|
||||
"sessionInfo_one": "{{count}} phiên - hoạt động từ {{from}}%-{{to}}% thời lượng phiên",
|
||||
"sessionInfo_other": "{{count}} phiên - hoạt động từ {{from}}%-{{to}}% thời lượng phiên",
|
||||
"tooltip": {
|
||||
"lane": "Làn đồng thời",
|
||||
"sessionsWith": "Phiên có Agent này",
|
||||
"avgStart": "Bắt đầu TB trong phiên",
|
||||
"avgEnd": "Kết thúc TB trong phiên",
|
||||
"barHint": "Chiều dài thanh tỉ lệ với số phiên có loại Agent này. Phần trăm thời gian là trung bình trên các phiên đó.",
|
||||
"timing": {
|
||||
"wholeSession": "Hoạt động xuyên gần như cả phiên - chạy từ đầu tới cuối.",
|
||||
"frontLoadedFmt": "Dồn về đầu: thường hoạt động từ đầu phiên tới khoảng ~{{end}}% chặng đường.",
|
||||
"backLoadedFmt": "Dồn về cuối: thường khởi động quanh {{start}}% phiên và duy trì tới hết.",
|
||||
"tightFmt": "Phạm vi hẹp: chạy trong dải hẹp từ {{start}}% đến {{end}}% timeline phiên.",
|
||||
"midSessionFmt": "Công việc giữa phiên: chạy khoảng từ {{start}}% đến {{end}}% timeline phiên."
|
||||
}
|
||||
}
|
||||
},
|
||||
"complexity": {
|
||||
"title": "Biểu đồ phân tán độ phức tạp phiên",
|
||||
"subtitle": "Thời lượng vs số Agent vs token - kích thước bong bóng = lượng token",
|
||||
"noData": "Chưa có dữ liệu độ phức tạp phiên",
|
||||
"noDataDesc": "Phiên sẽ xuất hiện ở đây sau khi hoàn tất với hoạt động Agent.",
|
||||
"duration": "Thời lượng",
|
||||
"agentCount": "Số Agent",
|
||||
"ariaLabel": "Biểu đồ phân tán độ phức tạp phiên",
|
||||
"tooltip": {
|
||||
"duration": "Thời lượng:",
|
||||
"agents": "Agent:",
|
||||
"subagents": "Subagent:",
|
||||
"tokens": "Token:",
|
||||
"model": "Mô hình:"
|
||||
}
|
||||
},
|
||||
"compaction": {
|
||||
"title": "Phân tích tác động nén ngữ cảnh",
|
||||
"subtitle": "Cách nén ngữ cảnh phân bố trên các phiên của bạn",
|
||||
"help": "Khi cửa sổ ngữ cảnh của một phiên đầy, Claude Code nén phần lịch sử cũ thành bản tóm tắt. Mỗi lần nén dưới đây là một sự kiện như vậy.",
|
||||
"noData": "Chưa ghi nhận nén ngữ cảnh",
|
||||
"totalCompactions": "Tổng số lần nén",
|
||||
"sessionsAffected": "Phiên bị ảnh hưởng",
|
||||
"ofTotal": "trên {{total}}",
|
||||
"avgPerSession": "TB / phiên bị ảnh hưởng",
|
||||
"peakSession": "Nhiều nhất trong một phiên",
|
||||
"tokensRecovered": "Token thu hồi",
|
||||
"distribution": "Số phiên theo số lần nén",
|
||||
"xAxis": "Số lần nén mỗi phiên",
|
||||
"yAxis": "Số phiên",
|
||||
"summary": "{{affected}} trên {{total}} phiên ({{pct}}%) đã dùng nén ngữ cảnh.",
|
||||
"tokensFreed": "Nén đã giải phóng ~{{tokens}} token ngữ cảnh.",
|
||||
"ariaLabel": "Số phiên theo số lần nén",
|
||||
"tipTitle": "Nén {{count}} lần",
|
||||
"tipDetail": "{{sessions}} phiên · {{pct}}% số phiên bị ảnh hưởng"
|
||||
},
|
||||
"drillIn": {
|
||||
"title": "Phân tích chi tiết phiên",
|
||||
"subtitle": "Chọn phiên từ biểu đồ phân tán hoặc dùng danh sách để khám phá",
|
||||
@@ -464,5 +234,10 @@
|
||||
"showingOf": "Đang hiển thị 100 / {{total}} sự kiện",
|
||||
"sessionInfo": " phiên - hoạt động {{from}}%-{{to}} thời lượng phiên",
|
||||
"sortedByCompaction": " phiên (sắp theo số lần nén ngữ cảnh)"
|
||||
},
|
||||
"lanes": {
|
||||
"title": "Pipeline của lane",
|
||||
"subtitle": "Các lane của bạn và giai đoạn mỗi lane đã đạt tới",
|
||||
"openWorkspace": "Mở trong Workspace"
|
||||
}
|
||||
}
|
||||
|
||||
+21
-104
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* @file Workflows.tsx
|
||||
* @description Displays comprehensive analytics on agent orchestration patterns, including DAGs of agent spawning, tool usage flows, collaboration networks, and session complexity metrics, with real-time updates and interactive filtering.
|
||||
* @description Workflows page: this dashboard's own lane pipelines first, then the
|
||||
* few Claude Code orchestration views that stay readable and interactive — headline
|
||||
* stats, Workflow-tool runs, the spawning DAG, the tool flow, and the per-session
|
||||
* drill-in. The deeper analytics (collaboration network, subagent effectiveness,
|
||||
* patterns, model delegation, error propagation, concurrency, complexity,
|
||||
* compaction) were dropped in issue #6 as too specialized to act on.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -25,14 +30,11 @@
|
||||
* - `../lib/eventBus`
|
||||
* - `../lib/types`
|
||||
* - `../components/workflows/WorkflowStats`
|
||||
* - `../components/workflows/LanePipelinePanel`
|
||||
* - `../components/workflows/WorkflowRunsPanel`
|
||||
* - `../components/workflows/OrchestrationDAG`
|
||||
* - `../components/workflows/ToolExecutionFlow`
|
||||
* - `../components/workflows/AgentCollaborationNetwork`
|
||||
* - `../components/workflows/SubagentEffectiveness`
|
||||
* - `../components/workflows/WorkflowPatterns`
|
||||
* - `../components/workflows/ModelDelegationFlow`
|
||||
* - `../components/workflows/ErrorPropagationMap`
|
||||
* - `../components/workflows/ConcurrencyTimeline`
|
||||
* - `../components/workflows/SessionDrillIn`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Workflows` — exported API; see TSDoc on the symbol for behavior.
|
||||
@@ -74,16 +76,9 @@ import type { WorkflowData, WSMessage } from "../lib/types";
|
||||
import { WorkflowStats } from "../components/workflows/WorkflowStats";
|
||||
import { OrchestrationDAG } from "../components/workflows/OrchestrationDAG";
|
||||
import { ToolExecutionFlow } from "../components/workflows/ToolExecutionFlow";
|
||||
import { AgentCollaborationNetwork } from "../components/workflows/AgentCollaborationNetwork";
|
||||
import { SubagentEffectiveness } from "../components/workflows/SubagentEffectiveness";
|
||||
import { WorkflowPatterns } from "../components/workflows/WorkflowPatterns";
|
||||
import { ModelDelegationFlow } from "../components/workflows/ModelDelegationFlow";
|
||||
import { ErrorPropagationMap } from "../components/workflows/ErrorPropagationMap";
|
||||
import { ConcurrencyTimeline } from "../components/workflows/ConcurrencyTimeline";
|
||||
import { SessionComplexityScatter } from "../components/workflows/SessionComplexityScatter";
|
||||
import { CompactionImpact } from "../components/workflows/CompactionImpact";
|
||||
import { SessionDrillIn } from "../components/workflows/SessionDrillIn";
|
||||
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
|
||||
import { LanePipelinePanel } from "../components/workflows/LanePipelinePanel";
|
||||
|
||||
type StatusFilter = "all" | "active" | "completed";
|
||||
|
||||
@@ -203,6 +198,11 @@ export function Workflows() {
|
||||
{/* Stats Row */}
|
||||
<WorkflowStats stats={data.stats} />
|
||||
|
||||
{/* This dashboard's own pipelines - lane strip + the selected lane's map */}
|
||||
<Section number={1} title={t("lanes.title")} subtitle={t("lanes.subtitle")} infoKey="lanes">
|
||||
<LanePipelinePanel />
|
||||
</Section>
|
||||
|
||||
{/* Workflow-tool runs (issue #167) - fleets ingested from on-disk journals */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div>
|
||||
@@ -215,9 +215,9 @@ export function Workflows() {
|
||||
<WorkflowRunsPanel statusFilter={statusFilter} />
|
||||
</div>
|
||||
|
||||
{/* Section 1: Agent Orchestration DAG */}
|
||||
{/* Section 2: Agent Orchestration DAG */}
|
||||
<Section
|
||||
number={1}
|
||||
number={2}
|
||||
title={t("orchestration.title")}
|
||||
subtitle={t("orchestration.subtitle")}
|
||||
infoKey="orchestration"
|
||||
@@ -243,9 +243,9 @@ export function Workflows() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Section 2: Tool Execution Flow */}
|
||||
{/* Section 3: Tool Execution Flow */}
|
||||
<Section
|
||||
number={2}
|
||||
number={3}
|
||||
title={t("toolFlow.title")}
|
||||
subtitle={t("toolFlow.subtitle")}
|
||||
infoKey="toolFlow"
|
||||
@@ -253,92 +253,9 @@ export function Workflows() {
|
||||
<ToolExecutionFlow data={data.toolFlow} filterAgentType={selectedNode} />
|
||||
</Section>
|
||||
|
||||
{/* Section 3: Agent Collaboration Network */}
|
||||
{/* Section 4: Session Drill-In */}
|
||||
<Section
|
||||
number={3}
|
||||
title={t("pipeline.title")}
|
||||
subtitle={t("pipeline.subtitle")}
|
||||
infoKey="pipeline"
|
||||
>
|
||||
<AgentCollaborationNetwork effectiveness={data.effectiveness} edges={data.cooccurrence} />
|
||||
</Section>
|
||||
|
||||
{/* Section 4 + 5: Two Column */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Section
|
||||
number={4}
|
||||
title={t("effectiveness.title")}
|
||||
subtitle={t("effectiveness.subtitle")}
|
||||
infoKey="effectiveness"
|
||||
>
|
||||
<SubagentEffectiveness data={data.effectiveness} />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
number={5}
|
||||
title={t("patterns.title")}
|
||||
subtitle={t("patterns.subtitle")}
|
||||
infoKey="patterns"
|
||||
>
|
||||
<WorkflowPatterns data={data.patterns} onPatternClick={() => {}} />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Section 6 + 7: Two Column */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Section
|
||||
number={6}
|
||||
title={t("modelDelegation.title")}
|
||||
subtitle={t("modelDelegation.subtitle")}
|
||||
infoKey="modelDelegation"
|
||||
>
|
||||
<ModelDelegationFlow data={data.modelDelegation} />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
number={7}
|
||||
title={t("errorPropagation.title")}
|
||||
subtitle={t("errorPropagation.subtitle")}
|
||||
infoKey="errorPropagation"
|
||||
>
|
||||
<ErrorPropagationMap data={data.errorPropagation} />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Section 8: Agent Concurrency Timeline */}
|
||||
<Section
|
||||
number={8}
|
||||
title={t("concurrency.title")}
|
||||
subtitle={t("concurrency.subtitle")}
|
||||
infoKey="concurrency"
|
||||
>
|
||||
<ConcurrencyTimeline data={data.concurrency} />
|
||||
</Section>
|
||||
|
||||
{/* Section 9 + 10: Two Column */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Section
|
||||
number={9}
|
||||
title={t("complexity.title")}
|
||||
subtitle={t("complexity.subtitle")}
|
||||
infoKey="complexity"
|
||||
>
|
||||
<SessionComplexityScatter data={data.complexity} onSessionClick={setSelectedSessionId} />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
number={10}
|
||||
title={t("compaction.title")}
|
||||
subtitle={t("compaction.subtitle")}
|
||||
infoKey="compaction"
|
||||
>
|
||||
<CompactionImpact data={data.compaction} />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Section 11: Session Drill-In */}
|
||||
<Section
|
||||
number={11}
|
||||
number={4}
|
||||
title={t("drillIn.title")}
|
||||
subtitle={t("drillIn.subtitle")}
|
||||
infoKey="drillIn"
|
||||
|
||||
@@ -10264,6 +10264,78 @@ exports[`screen snapshots > Workflows 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 mb-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2.5 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Lane Pipelines
|
||||
</h2>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="What this shows"
|
||||
class="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-fg-muted"
|
||||
title="Your own lanes and the stage each one has reached"
|
||||
>
|
||||
Your own lanes and the stage each one has reached
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="card p-4 flex-1"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
No lanes yet. Create one from a working directory:
|
||||
|
||||
<code>
|
||||
ccam lanes add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="card p-4 space-y-3"
|
||||
>
|
||||
@@ -10453,7 +10525,7 @@ exports[`screen snapshots > Workflows 1`] = `
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
1
|
||||
2
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
@@ -10569,7 +10641,7 @@ exports[`screen snapshots > Workflows 1`] = `
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
2
|
||||
3
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
@@ -10760,766 +10832,7 @@ exports[`screen snapshots > Workflows 1`] = `
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
3
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Agent Pipeline Graph
|
||||
</h2>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="What this shows"
|
||||
class="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-fg-muted"
|
||||
title="Directed workflow - which agent types run after which, with frequency labels"
|
||||
>
|
||||
Directed workflow - which agent types run after which, with frequency labels
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="card p-4 flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-16 text-center"
|
||||
>
|
||||
<p
|
||||
class="text-sm font-medium text-fg-secondary"
|
||||
>
|
||||
No pipeline data yet
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-fg-muted mt-1"
|
||||
>
|
||||
Directed workflow edges appear once agent types run sequentially across multiple sessions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-2 gap-6"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 mb-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2.5 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
4
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Subagent Effectiveness
|
||||
</h2>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="What this shows"
|
||||
class="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-fg-muted"
|
||||
title="Performance metrics per agent type"
|
||||
>
|
||||
Performance metrics per agent type
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="card p-4 flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center py-16 text-fg-muted text-sm"
|
||||
>
|
||||
No subagent data available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 mb-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2.5 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
5
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Detected Workflow Patterns
|
||||
</h2>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="What this shows"
|
||||
class="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-fg-muted"
|
||||
title="Common agent orchestration sequences"
|
||||
>
|
||||
Common agent orchestration sequences
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="card p-4 flex-1"
|
||||
>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-secondary uppercase tracking-wider mb-4"
|
||||
>
|
||||
Workflow Patterns
|
||||
</h2>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-12 text-center"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-zap w-5 h-5 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-medium text-fg-secondary"
|
||||
>
|
||||
No patterns detected yet
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-fg-muted mt-1"
|
||||
>
|
||||
Patterns appear once multiple sessions share the same agent sequence.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-2 gap-6"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 mb-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2.5 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
6
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Model Delegation Flow
|
||||
</h2>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="What this shows"
|
||||
class="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-fg-muted"
|
||||
title="How models route through agent hierarchies"
|
||||
>
|
||||
How models route through agent hierarchies
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="card p-4 flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-16 gap-3 text-fg-muted"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="40"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width="40"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 8v4M12 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-sm"
|
||||
>
|
||||
No model delegation data available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 mb-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2.5 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
7
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Error Propagation Map
|
||||
</h2>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="What this shows"
|
||||
class="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-fg-muted"
|
||||
title="Where errors cluster in agent hierarchy depth"
|
||||
>
|
||||
Where errors cluster in agent hierarchy depth
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="card p-4 flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-16 gap-3"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-status-success/10 border border-status-success/20 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="#10b981"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
>
|
||||
<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
|
||||
class="text-sm text-status-success font-medium"
|
||||
>
|
||||
No errors recorded
|
||||
</span>
|
||||
<span
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
All sessions completed successfully
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 mb-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2.5 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
8
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Agent Concurrency Timeline
|
||||
</h2>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="What this shows"
|
||||
class="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-fg-muted"
|
||||
title="Parallel agent execution patterns - Shows how agents overlap in time"
|
||||
>
|
||||
Parallel agent execution patterns - Shows how agents overlap in time
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="card p-4 flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-12 text-center"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-fg-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect
|
||||
height="4"
|
||||
rx="1"
|
||||
width="18"
|
||||
x="3"
|
||||
y="4"
|
||||
/>
|
||||
<rect
|
||||
height="4"
|
||||
rx="1"
|
||||
width="12"
|
||||
x="3"
|
||||
y="10"
|
||||
/>
|
||||
<rect
|
||||
height="4"
|
||||
rx="1"
|
||||
width="15"
|
||||
x="3"
|
||||
y="16"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-medium text-fg-secondary"
|
||||
>
|
||||
No concurrency data
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-fg-muted mt-1"
|
||||
>
|
||||
Concurrency data appears once sessions with subagents have been recorded.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-2 gap-6"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 mb-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2.5 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
9
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Session Complexity Scatter
|
||||
</h2>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="What this shows"
|
||||
class="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-fg-muted"
|
||||
title="Duration vs agent count vs tokens - Bubble size = token usage"
|
||||
>
|
||||
Duration vs agent count vs tokens - Bubble size = token usage
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="card p-4 flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-16 text-center"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-fg-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
cx="7"
|
||||
cy="12"
|
||||
r="3"
|
||||
/>
|
||||
<circle
|
||||
cx="17"
|
||||
cy="8"
|
||||
r="2"
|
||||
/>
|
||||
<circle
|
||||
cx="14"
|
||||
cy="17"
|
||||
r="4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-medium text-fg-secondary"
|
||||
>
|
||||
No session complexity data
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-fg-muted mt-1"
|
||||
>
|
||||
Sessions will appear here once they complete with agent activity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 mb-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2.5 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
10
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Compaction Impact Analysis
|
||||
</h2>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="What this shows"
|
||||
class="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-fg-muted"
|
||||
title="How context compression is spread across your sessions"
|
||||
>
|
||||
How context compression is spread across your sessions
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="card p-4 flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-16 gap-3 text-fg-muted"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="40"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width="40"
|
||||
>
|
||||
<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
|
||||
class="text-sm"
|
||||
>
|
||||
No compactions recorded
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 mb-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2.5 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
11
|
||||
4
|
||||
</span>
|
||||
<h2
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
|
||||
Reference in New Issue
Block a user