feat(lanes): add pipeline-template picker to the Workspace lane header

Lets a lane's pipeline template be switched live from the dashboard
(the same PATCH /api/lanes/:id the CLI's `ccam lanes pipeline` uses),
so lanes created before the picker shipped don't need the terminal.
Both ship-feature skills now force their own template before their
first `ccam stage` call, so the human never has to pick correctly at
lane creation.
This commit is contained in:
2026-08-11 12:15:16 +07:00
parent f6946d72f4
commit f0ae876472
9 changed files with 91 additions and 6 deletions
+55 -2
View File
@@ -132,6 +132,9 @@ export function Workspace() {
const [addLaneOpen, setAddLaneOpen] = useState(false);
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
const [features, setFeatures] = useState<LaneFeature[]>([]);
const [pipelineTemplates, setPipelineTemplates] = useState<
{ id: string; name: string; nodes: { id: string }[] }[]
>([]);
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
@@ -202,7 +205,10 @@ export function Workspace() {
api.run
.binary()
.then(setBinaryStatus)
.catch(() => setBinaryStatus({ found: false, path: null }));
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
// `claude` is missing from PATH — leave the probe unresolved rather than
// showing a misleading "claude missing" banner for an unrelated fault.
.catch(() => undefined);
api.run
.list()
.then(setActiveRuns)
@@ -211,6 +217,10 @@ export function Workspace() {
.history(50)
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
api.lanes
.pipelines()
.then((r) => setPipelineTemplates(r.pipelines))
.catch(() => undefined);
void refreshLanes();
api.run
.cwds()
@@ -856,6 +866,28 @@ export function Workspace() {
}
};
// Mirrors `ccam lanes pipeline <template> <id>`: same PATCH, same
// stage-mismatch warning when the current declared stage matches no node
// in the newly chosen template.
const handlePipelineChange = async (id: number, pipeline: string) => {
setLaneActionError(null);
try {
const { lane } = await api.lanes.update(id, { pipeline });
await refreshLanes();
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
setLaneActionError(
tLanes("pipelinePicker.stageMismatch", {
stage: lane.stage,
pipeline: lane.pipeline,
nodes: lane.pipeline_nodes.map((n) => n.id).join(", "),
})
);
}
} catch (err) {
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
}
};
const consoleSection = (
<>
{/* Always attached under the pipeline - no header, no collapse. The
@@ -1036,7 +1068,28 @@ export function Workspace() {
<span className="truncate text-sm font-semibold text-fg-primary">
{currentLane.title || currentLane.cwd}
</span>
<span className="text-[11px] text-fg-muted">{currentLane.pipeline_name}</span>
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
viewedFeature
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
: undefined
}
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
>
{(pipelineTemplates.length
? pipelineTemplates
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
).map((p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
))}
</select>
{/* `stage` defaults to the DB sentinel "idle" until the driving
session ever calls `ccam stage` — that string collides with
`status`'s own "idle"/"running" vocabulary, so a lane that is
@@ -41,6 +41,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
send: r({ messageId: "m-1" }),
kill: r({ ok: true }),
},
lanes: {
list: r({ lanes: [], counts: { total: 0, running: 0, needs_you: 0, dead: 0 } }),
pipelines: r({ pipelines: [] }),
},
ccConfig: {
commands: r({ items: [] }),
plugins: r({ plugins: [] }),
@@ -105,6 +105,15 @@ vi.mock("../../lib/api", async (importOriginal) => {
recordCall("POST", `/api/lanes/stage`);
return { ok: true };
}),
pipelines: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes/pipelines");
return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
}),
update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
recordCall("PATCH", `/api/lanes/${id}`);
const lane = lanesToReturn.find((l) => l.id === id);
return { lane: { ...lane, ...patch } };
}),
features: {
list: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/features`);
@@ -387,6 +387,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
create: r({ lane: {} }),
update: r({ lane: {} }),
stage: r({ lane: {} }),
pipelines: r({ pipelines: [] }),
// A realistic shape: `{}` would crash blockingReason on `blocked` if any
// screen ever opened the destructive modal.
preflight: r({