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:
@@ -94,6 +94,8 @@
|
||||
"locks.held_one": "{{count}} lock held",
|
||||
"locks.held_other": "{{count}} locks held",
|
||||
"moreActions": "More actions",
|
||||
"pipelinePicker.label": "Pipeline template",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||
"preflightError": "Could not load the current lane facts.",
|
||||
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}",
|
||||
"runtime.boot": "▶ up",
|
||||
|
||||
@@ -94,6 +94,8 @@
|
||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||
"moreActions": "Thêm hành động",
|
||||
"pipelinePicker.label": "Mẫu pipeline",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||
"preflightError": "Không thể tải trạng thái làn đường hiện tại.",
|
||||
"preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}",
|
||||
"runtime.boot": "▶ chạy",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user