feat(lanes): let Add Lane choose the pipeline template

Creation is the only point the UI could ever set a lane's template, and it
never offered the choice — so every lane added from "+ Add lane" was born
on `default` and rendered an 8-node map for a 16-node workflow, with no
screen able to change it afterwards. That is the defect that made the
ship-feature template unreachable from the browser.

The modal now shows a *Pipeline template* select fed by
`GET /api/lanes/pipelines`, labelled with each template's node count so the
consequence of the choice is visible. A failed fetch degrades to a `default`
option rather than blocking lane creation.

`pipeline` was already accepted by `POST /api/lanes` but silently dropped by
`/ensure` and `/worktree`, which build their own createLane payloads; both
now pass it through, and both map `EBADPIPELINE` to 400 like `EBADCWD`.
This commit is contained in:
2026-08-07 09:44:48 +07:00
parent 67edda77eb
commit 8fcef5a10b
8 changed files with 143 additions and 7 deletions
+52 -1
View File
@@ -92,6 +92,8 @@ export function AddLaneModal({
const [branch, setBranch] = useState("");
const [branches, setBranches] = useState<string[] | null>(null);
const [base, setBase] = useState("");
const [pipeline, setPipeline] = useState("default");
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
const [branchesError, setBranchesError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -109,12 +111,35 @@ export function AddLaneModal({
setBranch("");
setBranches(null);
setBase("");
setPipeline("default");
setBranchesError(null);
setError(null);
setBusy(false);
setSetupResult(null);
};
// The template a lane is created with is the ONLY chance to get it right
// from here: nothing else in the UI can change it afterwards, so a lane
// silently born on `default` renders an 8-node map for a 16-node workflow.
// Fetched on open (templates are file-backed and can change between opens).
useEffect(() => {
if (!open) return;
let cancelled = false;
api.lanes
.pipelines()
.then((r) => {
if (!cancelled) setPipelines(r.pipelines);
})
.catch(() => {
// Quiet: the select just falls back to the single `default` option
// below, and the lane still gets created.
if (!cancelled) setPipelines([]);
});
return () => {
cancelled = true;
};
}, [open]);
// Look up the repo's branches once the path settles - debounced so every
// keystroke while typing a path doesn't fire a request against a path that
// isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and
@@ -158,7 +183,11 @@ export function AddLaneModal({
if (mode === "repo") {
try {
const result = await api.lanes.ensure({ cwd: repo, title: name || undefined });
const result = await api.lanes.ensure({
cwd: repo,
title: name || undefined,
pipeline,
});
onAdded(result.lane);
reset();
onClose();
@@ -176,6 +205,7 @@ export function AddLaneModal({
title: name,
base: base || undefined,
branch: branch.trim(),
pipeline,
});
onAdded(result.lane);
@@ -318,6 +348,27 @@ export function AddLaneModal({
/>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
{t("addLanePipelineLabel")}
</label>
<select
id="add-lane-pipeline"
value={pipeline}
onChange={(e) => setPipeline(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
(p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
)
)}
</select>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
</div>
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">