/** * @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. 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 { CwdSuggestion } from "../../lib/api"; import type { Lane } 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"); const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]); const [branchesError, setBranchesError] = useState(null); 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"; } | null>(null); const reset = () => { setMode("worktree"); setSourceRepo(""); setTitle(""); 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 // 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]); 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, }); onAdded(result.lane); 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, }); 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] = await Promise.allSettled([ api.lanes.profileInit(result.lane.id), api.lanes.agentsInstall(result.lane.id), api.lanes.mcpSync(result.lane.id), ]); setSetupResult({ profile: profileOutcome.status === "fulfilled" ? profileOutcome.value.scaffolded ? "scaffolded" : "skipped" : "failed", agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed", mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed", }); 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, }); } // Leave the modal open so the setup summary below stays on screen; the // user dismisses it themselves (Cancel/X) once they've seen it, rather // than racing a timer that can close before they've looked at it. setBusy(false); } 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]); const disabled = !!setupResult || !sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim())); return (
setMode("repo")} /> setMode("worktree")} />

{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")}

{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" }, ] as const ).map((row) => (

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

))}
)} {error && (

{error}

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