From 20d85d345070433da8bd5708415dcbc78c651ff2 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 26 Aug 2026 11:19:39 +0700 Subject: [PATCH] feat(workflows): pick a lane in the pipeline pass-rate panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel aggregated every lane at once, which answers "does this stage get proved across the project" but never "does THIS lane prove it". A lane picker now narrows the same aggregation through ?lane= on GET /api/lanes/pipeline-stats, counting that lane plus its own archived features — dropping the features would hide most of a lane's history the moment it starts its next one. The response carries a `lanes` array that the filter never narrows: it populates the picker, so it has to keep offering every other lane even while the counts show one. For the same reason the picker renders above the empty branch rather than inside the populated one — narrowing to a lane that has recorded nothing would otherwise be a one-way trip, with no control left to select anything else. A lane filter that is not a positive integer is rejected with 400 EBADLANE rather than silently aggregating everything. --- .../workflows/LanePipelinePanel.tsx | 46 ++++++++++++++++--- .../__tests__/LanePipelinePanel.test.tsx | 40 ++++++++++++++-- client/src/i18n/locales/en/workflows.json | 5 +- client/src/i18n/locales/vi/workflows.json | 5 +- client/src/lib/api.ts | 8 +++- client/src/lib/types.ts | 2 + .../screens.snapshot.test.tsx.snap | 20 ++++---- .../pages/__tests__/screens.snapshot.test.tsx | 2 +- docs/API.md | 17 +++++-- docs/LANES.md | 5 ++ server/__tests__/workflows-usage-api.test.js | 46 +++++++++++++++++++ server/routes/lanes.js | 32 ++++++++++--- 12 files changed, 195 insertions(+), 33 deletions(-) diff --git a/client/src/components/workflows/LanePipelinePanel.tsx b/client/src/components/workflows/LanePipelinePanel.tsx index 97da2187..22571cc3 100644 --- a/client/src/components/workflows/LanePipelinePanel.tsx +++ b/client/src/components/workflows/LanePipelinePanel.tsx @@ -4,8 +4,10 @@ * 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. + * everyone claims but nobody proves is visible at a glance. A lane picker + * narrows the same aggregation to one lane and its archived features. + * Read-only — the live per-lane view and every lane action stay on the + * Workspace page. * @author Nguyễn Ngọc Trí Vĩ */ @@ -28,16 +30,17 @@ export function LanePipelinePanel() { 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()); + setData(await api.lanes.pipelineStats(laneId)); setError(null); } catch (err) { setError(err instanceof Error ? err.message : t("failedLoad")); } - }, [t]); + }, [t, laneId]); useEffect(() => { void refresh(); @@ -62,11 +65,39 @@ export function LanePipelinePanel() { // 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 ( -

- {t("lanes.empty")} ccam lanes add --cwd $(pwd) -

+
+ {lanePicker} +

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

+
); } @@ -75,6 +106,7 @@ export function LanePipelinePanel() { return (
+ {lanePicker} {data.pipelines.length > 1 && (