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:
@@ -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">
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
branches: vi.fn(),
|
||||
pipelines: vi.fn(),
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
@@ -101,6 +102,18 @@ async function fillWorktreeForm(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.pipelines)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
pipelines: [
|
||||
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
|
||||
{
|
||||
id: "ship-feature",
|
||||
name: "Ship feature (lane pipeline)",
|
||||
nodes: new Array(16).fill({ id: "n" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
@@ -174,6 +187,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
title: "New feature",
|
||||
base: "main",
|
||||
branch: "feat/new-feature",
|
||||
pipeline: "default",
|
||||
});
|
||||
});
|
||||
expect(onAdded).toHaveBeenCalledWith(
|
||||
@@ -327,6 +341,7 @@ describe("AddLaneModal — repo mode (adopt)", () => {
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith({
|
||||
cwd: "/Users/tester/projects/repo",
|
||||
title: "main repo",
|
||||
pipeline: "default",
|
||||
})
|
||||
);
|
||||
expect(api.lanes.worktree).not.toHaveBeenCalled();
|
||||
@@ -376,3 +391,47 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — pipeline template", () => {
|
||||
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
|
||||
// Creation is the ONLY point the UI can set a template, so a lane born on
|
||||
// `default` renders 8 nodes for a 16-node workflow with no way back from
|
||||
// any screen.
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
|
||||
).toBeInTheDocument();
|
||||
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.selectOptions(select, "ship-feature");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pipeline: "ship-feature" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
|
||||
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
|
||||
renderModal();
|
||||
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() => expect(select).toHaveValue("default"));
|
||||
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user