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
@@ -0,0 +1,113 @@
/**
* @file SkillUsagePanel.tsx
* @description Which skills and plugins actually get used, from
* `GET /api/workflows/skill-usage`: plugin totals as chips (skill calls + the
* plugin's own MCP tool calls) over a ranked list of individual skills with
* their call count and how many sessions reached for them. A skill that is
* installed but never invoked simply never appears — the panel reports use, not
* inventory.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import type { SkillUsage } from "../../lib/types";
export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
const { t } = useTranslation("workflows");
const [data, setData] = useState<SkillUsage | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
setData(await api.workflows.skillUsage(statusFilter));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("failedLoad"));
}
}, [statusFilter, t]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const unsub = eventBus.subscribe(() => {
clearTimeout(timer);
timer = setTimeout(() => void refresh(), 3000);
});
return () => {
unsub();
clearTimeout(timer);
};
}, [refresh]);
if (error) return <p className="text-xs text-status-danger">{error}</p>;
if (!data) return null;
if (!data.skills.length && !data.plugins.length) {
return <p className="text-sm text-fg-muted">{t("skillUsage.empty")}</p>;
}
const topCount = Math.max(...data.skills.map((s) => s.count), 1);
return (
<div className="space-y-4">
{data.plugins.length > 0 && (
<div>
<p className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("skillUsage.plugins")}
</p>
<div className="flex flex-wrap gap-1.5">
{data.plugins.map((p) => (
<span
key={p.name}
data-testid={`plugin-usage-${p.name}`}
title={t("skillUsage.pluginBreakdown", {
skills: p.skills,
skillCalls: p.skillCalls,
mcpCalls: p.mcpCalls,
})}
className="rounded-full border border-border bg-surface-2 px-2.5 py-1 text-xs text-fg-secondary"
>
{p.name}
<span className="ml-1.5 tabular-nums text-accent">{p.skillCalls + p.mcpCalls}</span>
</span>
))}
</div>
</div>
)}
<div>
<p className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("skillUsage.skills")}
</p>
<div className="space-y-1">
{data.skills.map((s) => (
<div
key={s.name}
data-testid={`skill-usage-${s.name}`}
className="flex items-center gap-2 text-xs"
>
<span className="w-56 shrink-0 truncate text-fg-secondary" title={s.name}>
{s.name}
</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-surface-3">
<div
className="h-full rounded-full bg-accent"
style={{ width: `${(s.count / topCount) * 100}%` }}
/>
</div>
<span className="w-32 shrink-0 text-right tabular-nums text-fg-muted">
{t("skillUsage.calls", { count: s.count })} ·{" "}
{t("skillUsage.sessions", { count: s.sessions })}
</span>
</div>
))}
</div>
</div>
</div>
);
}