/** * @file AddLaneModal.tsx * @description The "+ Add lane" flow, in one of two modes chosen with a * segmented toggle: "Repo" adopts an existing directory as-is via * `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a * main repo you want stage detection on); "Worktree" provisions a * dashboard-managed git worktree via `POST /api/lanes/worktree` with a * manually-typed branch name. The user also picks which CLI drives the lane * (Claude Code or Codex) — this choice is immutable after creation. Either * mode's path field can be filled by typing, by the CwdAutocomplete * suggestions, or by browsing (`FolderBrowseModal`) — a native folder picker * cannot hand a web page an absolute filesystem path, so browsing is * server-backed instead. The worktree lane returned is `status: "provisioning"`: * the route answers before the actual `git worktree add` runs, so this * component polls `GET /api/lanes/:id` until that finishes before running the * auto-setup calls against a cwd that must actually exist on disk first. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { FolderOpen } from "lucide-react"; import { ConfirmModal } from "../ConfirmModal"; import { CwdAutocomplete } from "../run/RunSetup"; import { FolderBrowseModal } from "./FolderBrowseModal"; import { api } from "../../lib/api"; import type { AiRouter, CwdSuggestion } from "../../lib/api"; import type { Lane, LaneTool } from "../../lib/types"; /** Polls the lane until the background `git worktree add` finishes (status * leaves "provisioning"), or gives up after `timeoutMs`. Returns the final * lane record, or `null` on timeout. */ async function waitForProvisioned( laneId: number, { intervalMs = 500, timeoutMs = 30000 } = {} ): Promise { const deadline = Date.now() + timeoutMs; for (;;) { const { lane } = await api.lanes.get(laneId); if (lane.status !== "provisioning") return lane; if (Date.now() >= deadline) return null; await new Promise((r) => window.setTimeout(r, intervalMs)); } } /** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */ function Seg({ active, label, title, onClick, }: { active: boolean; label: string; title?: string; onClick: () => void; }) { return ( ); } export function AddLaneModal({ open, onClose, onAdded, cwdSuggestions, }: { open: boolean; onClose: () => void; /** Called with the newly created (possibly still-provisioning) lane. */ onAdded: (lane: Lane) => void; /** The same suggestion list the Run form already fetched (dashboard cwd, * home, recently-used paths) — reused rather than fetched a second time. */ cwdSuggestions: CwdSuggestion[]; }) { const { t } = useTranslation(["lanes"]); const [mode, setMode] = useState<"repo" | "worktree">("worktree"); const [sourceRepo, setSourceRepo] = useState(""); const [title, setTitle] = useState(""); const [branch, setBranch] = useState(""); const [branches, setBranches] = useState(null); const [base, setBase] = useState(""); const [pipeline, setPipeline] = useState("default"); // "native" is the built-in profile, not a null state — see server/lib/ai-routers.js. const [router, setRouter] = useState("native"); const [routers, setRouters] = useState([]); const [routersLoaded, setRoutersLoaded] = useState(false); const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]); const [branchesError, setBranchesError] = useState(null); const [tool, setTool] = useState("claude"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [browseOpen, setBrowseOpen] = useState(false); const [setupResult, setSetupResult] = useState<{ profile: "scaffolded" | "skipped" | "failed"; agents: "ok" | "failed"; mcp: "ok" | "failed"; router: "ok" | "skipped" | "failed"; } | null>(null); const reset = () => { setMode("worktree"); setSourceRepo(""); setTitle(""); setBranch(""); setBranches(null); setBase(""); setPipeline("default"); setRouter("native"); setRoutersLoaded(false); setTool("claude"); 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([]); }); api.aiRouters .list() .then((r) => { if (cancelled) return; setRouters(r.items); // A profile deleted in another tab while this modal sat open would // otherwise stay selected in state while the select showed no matching // option — and submit would send a name the server no longer knows. setRouter((current) => current === "native" || r.items.some((item) => item.name === current) ? current : "native" ); }) .catch(() => { // Quiet: with no list the radio simply offers Native, and the lane is // still created — a router is an optional extra, never a precondition. if (!cancelled) setRouters([]); }) .finally(() => { if (!cancelled) setRoutersLoaded(true); }); 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 // never forks a branch. const lookedUpFor = useRef(""); useEffect(() => { if (mode !== "worktree") return; const path = sourceRepo.trim(); if (!path) { setBranches(null); setBase(""); setBranchesError(null); return; } const timer = window.setTimeout(async () => { lookedUpFor.current = path; try { const r = await api.lanes.branches(path); if (lookedUpFor.current !== path) return; // a newer path superseded this one setBranches(r.branches); setBase(r.current || r.branches[0] || ""); setBranchesError(null); } catch { if (lookedUpFor.current !== path) return; // Not yet a valid repo path (still being typed, or genuinely wrong) - // quiet by design, the same way CwdAutocomplete never errors either. setBranches(null); setBase(""); setBranchesError(t("addLaneNotARepo")); } }, 300); return () => window.clearTimeout(timer); }, [mode, sourceRepo, t]); // Native is the built-in profile, so this is one call either way — there is // no "no router" branch to keep in step with the real one. const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router); const submit = async () => { const repo = sourceRepo.trim(); const name = title.trim(); if (!repo) return; setBusy(true); setError(null); if (mode === "repo") { try { const result = await api.lanes.ensure({ cwd: repo, title: name || undefined, pipeline, tool, }); // Report the lane the INSTANT it exists server-side. Everything after // this is post-creation setup that can fail on its own, and letting one // of those failures skip `onAdded` would leave a real lane invisible in // the list until some unrelated refetch happened to pick it up. // Worktree mode below already works this way. onAdded(result.lane); // A session that booted on the vendor's API would keep talking to it // for its whole life, so the router is applied before anything runs. try { await applyRouter(result.lane.id); } catch (routerErr) { // The lane exists and is already listed — name what did not finish // instead of closing on a half-set-up lane. setError(routerErr instanceof Error ? routerErr.message : String(routerErr)); setBusy(false); return; } reset(); onClose(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setBusy(false); } return; } if (!branches || !name || !branch.trim()) return; try { const result = await api.lanes.worktree({ sourceRepo: repo, title: name, base: base || undefined, branch: branch.trim(), pipeline, tool, }); onAdded(result.lane); // POST /worktree returns as soon as the DB row exists (202) — the // actual `git worktree add` runs afterward, in the background, on the // server. Firing setup against the lane's cwd before that finishes // means agents/mcp write into a directory that doesn't exist yet, so // wait for provisioning to leave the "provisioning" status first. const provisionedLane = await waitForProvisioned(result.lane.id); if (!provisionedLane || provisionedLane.status === "failed") { setBusy(false); setError(t("addLaneProvisionFailed")); return; } const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([ api.lanes.profileInit(result.lane.id), api.lanes.agentsInstall(result.lane.id), api.lanes.mcpSync(result.lane.id), applyRouter(result.lane.id), ]); const outcome = { profile: profileOutcome.status === "fulfilled" ? profileOutcome.value.scaffolded ? "scaffolded" : "skipped" : "failed", agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed", mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed", router: routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok", } as const; const anyFailed = Object.values(outcome).includes("failed"); setSetupResult(outcome); if (import.meta.env.DEV) { console.info("[add-lane] auto-setup result:", { profile: profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason, agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason, mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason, // Never the profile itself: it would put an endpoint and a lane id in // the browser console. (The key is never sent here at all.) router: routerOutcome.status, }); } // A clean setup has nothing to tell anyone, so the lane is made and the // dialog is done — leaving it up read as "still working" (the confirm // button is dead from here, since the lane already exists) with no // obvious way out. A summary is kept on screen only when a step FAILED, // and then the confirm button becomes a plain close, because that is the // one case where the user has something to read before dismissing it. setBusy(false); if (!anyFailed) { reset(); onClose(); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); setBusy(false); } }; // ConfirmModal re-focuses its Cancel button in an effect keyed on `onCancel`'s // identity. Every keystroke in the fields below re-renders this component; an // inline `() => {...}` handed to `onCancel` would get a new identity each // time, re-running that effect and yanking focus off the field being typed // into after the very first character. useCallback keeps the identity stable // across renders so only mount/unmount (and a real onClose change) refocuses. const handleCancel = useCallback(() => { // ConfirmModal's own Escape/backdrop/X handling calls this directly. // While the folder browser is open on top of it, that dismissal should // close only the browser, not both modals at once. if (browseOpen) { setBrowseOpen(false); return; } reset(); onClose(); }, [onClose, browseOpen]); // A summary on screen means the lane already exists and a step failed, so // the confirm button has no second creation to run: it closes instead. That // is the difference between a dialog that is finished and one that is stuck. const done = !!setupResult; const disabled = !done && (!sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim()))); return (
setMode("repo")} /> setMode("worktree")} />
setTool("claude")} /> setTool("codex")} />

{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}

setTitle(e.target.value)} placeholder={t("addLaneTitlePlaceholder")} className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none" />

{t("addLanePipelineHint")}

{t("addLaneRouterLabel")}
{( [ { value: "native", label: t("addLaneRouterNative") }, { value: "router", label: t("addLaneRouterCustom") }, ] as const ).map((option) => { const active = option.value === "native" ? router === "native" : router !== "native"; // Clicking "AI Router" before the list lands would resolve back to // Native and read as a dead control. const disabled = option.value === "router" && !routersLoaded; return ( ); })}
{router !== "native" && ( )}

{!routersLoaded ? t("addLaneRouterLoading") : routers.some((r) => !r.builtin) ? t("addLaneRouterHint") : t("addLaneRouterEmptyHint")}

{mode === "worktree" && branches && (
{branches.length === 0 ? (

{t("addLaneNoBranches")}

) : ( )}
)} {mode === "worktree" && branchesError && !branches && (

{branchesError}

)} {mode === "worktree" && branches && (
setBranch(e.target.value)} placeholder={t("addLaneBranchPlaceholder")} className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none" />

{t("addLaneBranchHint")}

)} {setupResult && (

{t("addLaneSetupTitle")}

{( [ { label: t("addLaneSetupProfile"), ok: setupResult.profile !== "failed", skipped: setupResult.profile === "skipped", }, { label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" }, { label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" }, { label: t("addLaneSetupRouter"), ok: setupResult.router !== "failed", skipped: setupResult.router === "skipped", }, ] as const ).map((row) => (

{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"} {row.label}

))}
)} {error && (

{error}

)}
setBrowseOpen(false)} />
); }