feat(workflows): pipeline pass rates and skill/plugin usage

Restores the five analytics sections that turned out to be worth keeping
(patterns, model delegation, error propagation, session complexity, compaction
impact) and reworks what the page opens on.

The lane panel no longer mirrors the Workspace's live lane strip. It now
answers the question the strip cannot: how often each pipeline node actually
passes. GET /api/lanes/pipeline-stats aggregates every stage record of every
lane AND every archived feature — a lane's own `stages` only holds its current
feature, so without the archive the history resets each time a lane starts new
work. Evidence-backed passes are counted apart from bare claims, the same line
the pipeline map draws between `done` and `passed-no-evidence`.

Beside it, GET /api/workflows/skill-usage reports which skills and plugins the
sessions actually reach for. Only PreToolUse rows are counted: every call also
emits a PostToolUse, and counting both would double every number. A plugin's
total includes calls to the MCP tools it ships, so one used purely through MCP
does not read as unused.

Still dropped from issue #6: the agent collaboration network and the subagent
effectiveness scorecards.
This commit is contained in:
2026-08-26 10:32:13 +07:00
parent 4a0f99583d
commit 348c645ef3
23 changed files with 3944 additions and 239 deletions
@@ -1,41 +1,41 @@
/**
* @file LanePipelinePanel.tsx
* @description Read-only view of this dashboard's OWN pipelines on the Workflows
* page: a horizontally scrolling strip of lane tiles, and the selected lane's
* pipeline map underneath. It reuses the Workspace's `LaneStripCard` and
* `PipelineMap` so a stage reads identically on both pages. Read-only on
* purpose — the console-never-writes-a-stage boundary applies here too, so this
* panel only observes; every lane action stays on the Workspace page.
* @description How often each node of this dashboard's OWN pipelines actually
* gets passed, aggregated over every lane and every archived feature by
* `GET /api/lanes/pipeline-stats`. One row per node: a bar split into passed
* with evidence / passed without / failed, plus the raw counts, so a stage that
* everyone claims but nobody proves is visible at a glance. Read-only — the
* live per-lane view and every lane action stay on the Workspace page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import type { Lane } from "../../lib/types";
import LaneStripCard from "../lanes/LaneStripCard";
import PipelineMap from "../lanes/PipelineMap";
import type { PipelineStats } from "../../lib/types";
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
function pct(n: number, total: number): number {
return total > 0 ? (n / total) * 100 : 0;
}
/**
* Lane strip plus the selected lane's pipeline map.
* @returns The panel element; an empty-state hint when no lane exists yet.
* Per-node pass statistics for the selected pipeline template.
* @returns The panel element, or an empty-state hint when nothing was recorded.
*/
export function LanePipelinePanel() {
const { t } = useTranslation(["workflows", "lanes"]);
const [lanes, setLanes] = useState<Lane[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const { t } = useTranslation("workflows");
const [data, setData] = useState<PipelineStats | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const r = await api.lanes.list();
setLanes(r.lanes);
setData(await api.lanes.pipelineStats());
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("workflows:failedLoad"));
setError(err instanceof Error ? err.message : t("failedLoad"));
}
}, [t]);
@@ -43,7 +43,7 @@ export function LanePipelinePanel() {
void refresh();
}, [refresh]);
// Lanes move as hooks land; debounce so a burst of events costs one fetch.
// Stages move as agents declare them; debounce so a burst costs one fetch.
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const unsub = eventBus.subscribe(() => {
@@ -56,53 +56,106 @@ export function LanePipelinePanel() {
};
}, [refresh]);
// Follow the list rather than pinning an id: a lane can be removed elsewhere.
const selected = lanes.find((l) => l.id === selectedId) ?? lanes[0] ?? null;
if (error) return <p className="text-xs text-status-danger">{error}</p>;
if (!data) return null;
if (!lanes.length) {
// Follow the list rather than pinning an id: templates can come and go.
const selected = data.pipelines.find((p) => p.id === selectedId) ?? data.pipelines[0] ?? null;
if (!selected) {
return (
<p className="text-sm text-fg-muted">
{t("lanes:emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
{t("lanes.empty")} <code>ccam lanes add --cwd $(pwd)</code>
</p>
);
}
const maxEntered = Math.max(...selected.nodes.map((n) => n.entered), 1);
return (
<div className="space-y-3">
<div data-testid="workflows-lane-strip" className="flex flex-row gap-2 overflow-x-auto pb-1">
{lanes.map((l) => (
<LaneStripCard
key={l.id}
lane={l}
selected={selected?.id === l.id}
onSelect={() => setSelectedId(l.id)}
/>
))}
<div className="flex flex-wrap items-center gap-2">
{data.pipelines.length > 1 && (
<select
data-testid="pipeline-stats-picker"
aria-label={t("lanes.pipelineLabel")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
value={selected.id}
onChange={(e) => setSelectedId(e.target.value)}
>
{data.pipelines.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
)}
<span className="text-xs text-fg-muted">{t("lanes.runs", { count: selected.runs })}</span>
<div className="ml-auto flex items-center gap-3 text-[10px] text-fg-muted">
<LegendSwatch className="bg-status-success" label={t("lanes.legendEvidence")} />
<LegendSwatch className="bg-status-warning" label={t("lanes.legendClaimed")} />
<LegendSwatch className="bg-status-danger" label={t("lanes.legendFailed")} />
</div>
</div>
{selected && (
<div data-testid="workflows-lane-pipeline" className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-semibold text-fg-primary">
{t("lanes:laneHeader", {
id: selected.id,
title: selected.title || selected.cwd,
pipeline: selected.pipeline_name,
})}
</span>
<Link
to="/run"
className="ml-auto inline-flex items-center gap-1 text-xs text-fg-muted hover:text-accent"
<div className="space-y-1.5">
{selected.nodes.map((n) => {
const rate = pct(n.passed, n.entered);
return (
<div
key={n.id}
data-testid={`pipeline-stat-${n.id}`}
className="flex items-center gap-2 text-xs"
>
{t("workflows:lanes.openWorkspace")}
<ExternalLink className="h-3 w-3" />
</Link>
</div>
<PipelineMap nodes={selected.pipeline_nodes} detectedSignal={selected.detected_signal} />
</div>
)}
<span className="w-36 shrink-0 truncate text-fg-secondary" title={n.label}>
<span className="mr-1">{n.icon}</span>
{n.label}
</span>
{/* Bars are scaled against the busiest node, not against each
node's own total, so a node reached twice never looks as
travelled as one reached fifty times. */}
<div className="flex h-2.5 flex-1 overflow-hidden rounded-full bg-surface-3">
<div
className="h-full bg-status-success"
style={{ width: `${pct(n.withEvidence, maxEntered)}%` }}
title={t("lanes.legendEvidence")}
/>
<div
className="h-full bg-status-warning"
style={{ width: `${pct(n.passed - n.withEvidence, maxEntered)}%` }}
title={t("lanes.legendClaimed")}
/>
<div
className="h-full bg-status-danger"
style={{ width: `${pct(n.failed, maxEntered)}%` }}
title={t("lanes.legendFailed")}
/>
</div>
<span className="w-28 shrink-0 text-right tabular-nums text-fg-muted">
{n.entered === 0 ? (
t("lanes.never")
) : (
<>
<span className="text-fg-secondary">{n.passed}</span>/{n.entered}
<span className="ml-1">({Math.round(rate)}%)</span>
</>
)}
</span>
</div>
);
})}
</div>
</div>
);
}
function LegendSwatch({ className, label }: { className: string; label: string }) {
return (
<span className="flex items-center gap-1">
<span className={`h-2 w-2 rounded-sm ${className}`} />
{label}
</span>
);
}