feat(workflows): pick a lane in the pipeline pass-rate panel

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=<id> 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.
This commit is contained in:
2026-08-26 11:19:39 +07:00
parent 9696698d40
commit 20d85d3450
12 changed files with 195 additions and 33 deletions
@@ -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ĩ <vinnt@smartgift.vn>
*/
@@ -28,16 +30,17 @@ export function LanePipelinePanel() {
const { t } = useTranslation("workflows");
const [data, setData] = useState<PipelineStats | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [laneId, setLaneId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(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 && (
<select
data-testid="pipeline-stats-lane-picker"
aria-label={t("lanes.laneLabel")}
className="max-w-[14rem] truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t("lanes.allLanes")}</option>
{data.lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
);
if (!selected) {
return (
<p className="text-sm text-fg-muted">
{t("lanes.empty")} <code>ccam lanes add --cwd $(pwd)</code>
</p>
<div className="space-y-3">
{lanePicker}
<p className="text-sm text-fg-muted">
{laneId ? (
t("lanes.emptyLane")
) : (
<>
{t("lanes.empty")} <code>ccam lanes add --cwd $(pwd)</code>
</>
)}
</p>
</div>
);
}
@@ -75,6 +106,7 @@ export function LanePipelinePanel() {
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{lanePicker}
{data.pipelines.length > 1 && (
<select
data-testid="pipeline-stats-picker"
@@ -8,14 +8,14 @@
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { LanePipelinePanel } from "../LanePipelinePanel";
const statsMock = vi.fn();
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { lanes: { pipelineStats: () => statsMock() } } };
return { ...actual, api: { lanes: { pipelineStats: (id?: number | null) => statsMock(id) } } };
});
const node = (id: string, over: Record<string, number> = {}) => ({
@@ -45,6 +45,10 @@ const STATS = {
},
{ id: "hotfix", name: "Hotfix", runs: 1, nodes: [node("patch", { entered: 1, passed: 1 })] },
],
lanes: [
{ id: 1, title: "api lane", cwd: "/w/api" },
{ id: 2, title: "", cwd: "/w/ui" },
],
};
describe("LanePipelinePanel", () => {
@@ -91,12 +95,42 @@ describe("LanePipelinePanel", () => {
});
it("shows the CLI hint when nothing has been recorded", async () => {
statsMock.mockResolvedValue({ pipelines: [] });
statsMock.mockResolvedValue({ pipelines: [], lanes: [] });
render(<LanePipelinePanel />);
expect(await screen.findByText(/ccam lanes add/)).toBeInTheDocument();
});
it("refetches narrowed to the lane picked, and falls back to a cwd for an untitled one", async () => {
statsMock.mockResolvedValue(STATS);
render(<LanePipelinePanel />);
const picker = await screen.findByTestId("pipeline-stats-lane-picker");
expect(within(picker).getByText("/w/ui")).toBeInTheDocument();
expect(statsMock).toHaveBeenLastCalledWith(null);
fireEvent.change(picker, { target: { value: "2" } });
expect(statsMock).toHaveBeenLastCalledWith(2);
});
it("keeps the lane picker reachable when the picked lane recorded nothing", async () => {
statsMock.mockResolvedValue(STATS);
render(<LanePipelinePanel />);
// Without a picker in the empty branch, narrowing to a quiet lane would be
// a one-way trip — no control left to select any other lane.
statsMock.mockResolvedValue({ pipelines: [], lanes: STATS.lanes });
fireEvent.change(await screen.findByTestId("pipeline-stats-lane-picker"), {
target: { value: "1" },
});
// Wait for the empty branch to take over, then re-query: the refetch swaps
// the whole subtree, so a handle grabbed before it is already detached.
await waitFor(() => expect(screen.queryByTestId("pipeline-stat-plan")).not.toBeInTheDocument());
expect(screen.getByTestId("pipeline-stats-lane-picker")).toBeInTheDocument();
expect(screen.queryByText(/ccam lanes add/)).not.toBeInTheDocument();
});
it("surfaces a failed fetch", async () => {
statsMock.mockRejectedValue(new Error("stats unreachable"));
render(<LanePipelinePanel />);