/** * @file LanePipelinePanel.tsx * @description How often each node of this dashboard's OWN pipelines actually * gets passed, aggregated over every recorded run by * `GET /api/lanes/pipeline-stats`. One row per node: a bar split into passed * with evidence / passed without / failed / reached but only inferred, plus the * raw counts, so a stage that everyone claims but nobody proves is visible at a * glance. A lane picker narrows the same aggregation to one lane. * Read-only — the live per-lane view and every lane action stay on the * Workspace page. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../../lib/api"; import { useLiveRefresh } from "../../hooks/useLiveRefresh"; import { laneChangeMatcher } from "../../lib/laneChange"; 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; } /** * 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({ onAvailability, }: { /** Told whether any pipeline template is loaded (pipelines are dormant by default). */ onAvailability?: (hasPipelines: boolean) => void; } = {}) { const { t } = useTranslation("workflows"); const [data, setData] = useState(null); const [selectedId, setSelectedId] = useState(null); const [laneId, setLaneId] = useState(null); const [error, setError] = useState(null); const refresh = useCallback(async () => { try { setData(await api.lanes.pipelineStats(laneId)); setError(null); } catch (err) { setError(err instanceof Error ? err.message : t("failedLoad")); } }, [t, laneId]); useEffect(() => { void refresh(); }, [refresh]); // Stages move as agents declare them; throttled (not debounced) so a burst // costs one fetch and a steady stream still refreshes every 3 s. A lane's // re-broadcast that moved no stage column is ignored. const [stageChanged] = useState(() => laneChangeMatcher((l) => `${l.pipeline}:${l.stage}:${l.active_feature_id}`) ); useLiveRefresh(() => void refresh(), { match: stageChanged }); const hasPipelines = !!data && data.pipelines.length > 0; useEffect(() => { if (data) onAvailability?.(hasPipelines); }, [data, hasPipelines, onAvailability]); if (error) return

{error}

; if (!data) return null; // 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; // The lane picker sits above the empty branch, not inside the populated one: // narrowing to a lane with no stage records yet must still leave a way back. const lanePicker = data.lanes.length > 1 && ( ); if (!selected) { return (
{lanePicker}

{laneId ? ( t("lanes.emptyLane") ) : ( <> {t("lanes.empty")} agenthub workspace add --cwd $(pwd) )}

); } // Detected-only reaches share the scale: a node the hooks saw fifty times and // nobody ever declared should read as busy, not as empty. const maxEntered = Math.max(...selected.nodes.map((n) => n.entered + n.detected), 1); return (
{lanePicker} {data.pipelines.length > 1 && ( )} {t("lanes.runs", { count: selected.runs })}
{selected.nodes.map((n) => { const rate = pct(n.passed, n.entered); return (
{/* Label over the command that drives this node — the pass rate to the right answers "how often", this answers "by what". */} {n.icon} {n.label} {n.command && ( )} {/* 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. */}
{n.entered === 0 ? ( // Reached but never declared still beats "never reached" — it // is the difference between "you skip this stage" and "you do // it without saying so". n.detected > 0 ? ( t("lanes.detectedOnly", { count: n.detected }) ) : ( t("lanes.never") ) ) : ( <> {n.passed}/{n.entered} ({Math.round(rate)}%) )}
); })}
); } function LegendSwatch({ className, label }: { className: string; label: string }) { return ( {label} ); }