/** * @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 { ChevronDown, 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, LaneWorktree } from "../../lib/types"; import { useAiTools } from "../../hooks/useAiTools"; import { AiToolIcon } from "../AiToolIcon"; /** 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, prefill, }: { open: boolean; onClose: () => void; /** Called with the newly created (possibly still-provisioning) lane. */ onAdded: (lane: Lane) => void; /** Fields to open with, for "new lane from this repo" in a lane's git bar. * A branch here means the sibling worktree checks out THAT branch, which * is the existing-branch mode — the reason that mode exists (issue #34). */ prefill?: { sourceRepo?: string; branch?: string } | null; /** 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 [worktrees, setWorktrees] = useState([]); // Two ways to start a worktree lane, and git's own rule decides which is // possible: "new" forks a branch off `base`, "existing" checks out a branch // that is already there — which git allows exactly once across all of a // repo's worktrees, hence the busy markers below. const [branchMode, setBranchMode] = useState<"new" | "existing">("new"); const [base, setBase] = useState(""); // "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 [branchesError, setBranchesError] = useState(null); // null = the default AI tool (Settings → AI Tools), resolved at submit time. const [pickedTool, setPickedTool] = useState(null); const aiTools = useAiTools(); const defaultTool = aiTools.defaultId ?? "claude"; const tool = pickedTool ?? defaultTool; // Installed + enabled, the default first. const toolChoices = aiTools.tools .filter((x) => x.installed && x.enabled) .sort((a, b) => Number(b.id === defaultTool) - Number(a.id === defaultTool)); const [toolListOpen, setToolListOpen] = useState(false); const pickTool = (id: string) => { setPickedTool(id === defaultTool ? null : id); // A tool with no endpoint to rewrite runs native only (see router-apply.js). if (!aiTools.byId(id)?.routerCapable) setRouter("native"); setToolListOpen(false); }; const noTools = aiTools.loaded && aiTools.defaultId === null; const toolRouterCapable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [browseOpen, setBrowseOpen] = useState(false); const [setupResult, setSetupResult] = useState<{ profile: "scaffolded" | "skipped" | "failed"; mcp: "ok" | "failed"; router: "ok" | "skipped" | "failed"; } | null>(null); const reset = () => { setMode("worktree"); setSourceRepo(""); setTitle(""); setBranch(""); setBranches(null); setWorktrees([]); setBranchMode("new"); setBase(""); setRouter("native"); setRoutersLoaded(false); setPickedTool(null); setBranchesError(null); setError(null); setBusy(false); setSetupResult(null); }; // A prefill only ever seeds the fields ON OPEN: the user may then change // anything, and re-applying it on every render would fight them for the box. useEffect(() => { if (!open || !prefill) return; if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo); if (prefill.branch) { setBranch(prefill.branch); setBranchMode("existing"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); // A session that boots on the vendor's API keeps talking to it for its whole // life, so the router has to be picked here, before the lane exists. useEffect(() => { if (!open) return; let cancelled = false; 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); setWorktrees([]); 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); setWorktrees(r.worktrees || []); 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); setWorktrees([]); 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, tool, // THIS directory, not a lane on a parent of it (#42). exact: true, }); // 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); // Nothing was created: closing now reads as success while no new card // appears (#42). Say which lane already owns the folder instead. if (!result.created) { setError( t("addLaneExists", { id: result.lane.id, title: result.lane.title || result.lane.cwd, }) ); setBusy(false); return; } // 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; } // A blank branch in "new" mode is legal and deliberate: the server names // it `${LANE_BRANCH_PREFIX}${slug}`, which is what the placeholder previews. if (!branches || !name || (branchMode === "existing" && !branch.trim())) return; try { const result = await api.lanes.worktree({ sourceRepo: repo, title: name, // An existing branch is checked out as it stands; `base` only means // something when a branch is being forked. base: branchMode === "new" ? base || undefined : undefined, branch: branch.trim() || undefined, 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 mcp writes 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, mcpOutcome, routerOutcome] = await Promise.allSettled([ api.lanes.profileInit(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", 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, 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?.length || (branchMode === "existing" && !branch.trim())))); /** branch name -> the worktree directory already holding it. git allows one * checkout per branch, so these are the branches this form cannot use. */ const busyBranches = new Map( worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path]) ); const freeBranches = (branches || []).filter((b) => !busyBranches.has(b)); return (
setMode("repo")} /> setMode("worktree")} />
{noTools ? (

{t("addLaneNoTools")}{" "} {t("addLaneNoToolsLink")}

) : (
{toolListOpen && (
{toolChoices.map((x) => ( ))}
)}
)}

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

{/* What this repo already has checked out. Without it the form asked for a branch in the dark: the one fact that decides whether a branch can be used at all is which worktree is holding it. */} {mode === "worktree" && worktrees.length > 0 && (

{t("addLaneWorktreesTitle", { count: worktrees.length })}

    {worktrees.slice(0, 8).map((w) => (
  • ⑂ {w.branch || t("addLaneWorktreeDetached")} {w.path}
  • ))} {worktrees.length > 8 && (
  • {t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
  • )}
)}
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-accent focus:outline-none" />
{mode === "worktree" && branchesError && !branches && (

{branchesError}

)} {mode === "worktree" && branches && (
{ setBranchMode("new"); setBranch(""); }} /> { setBranchMode("existing"); // Land on something usable rather than on an empty select // that looks like a choice was already made. setBranch((current) => freeBranches.includes(current) ? current : freeBranches[0] || "" ); }} /> {/* The third answer to "which branch": none. A small or personal project usually wants the lane to BE the repo, working on whatever is checked out (`main`) — which is exactly Repo mode, so this jumps there rather than inventing a second way to do it. git cannot check `main` out in a second worktree while the repo itself holds it, so a worktree lane could not offer this even if it wanted to. */} setMode("repo")} />
{branchMode === "new" ? ( <>
{branches.length === 0 ? ( // No commit, nothing to fork: the server refuses this with // EEMPTYREPO, so the one way forward is offered instead.

{t("addLaneNoBranches")}

) : ( )}
setBranch(e.target.value)} // Neutral on purpose: an empty field lets the SERVER name // the branch, under a LANE_BRANCH_PREFIX this form cannot // see, so any `feat/…` preview here could be wrong. 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-accent focus:outline-none" />

{t("addLaneBranchHintAuto")}

) : (
{freeBranches.length === 0 ? (

{t("addLaneNoFreeBranches")}

) : ( )}

{t("addLaneBranchExistingHint")}

)}
)} {/* The router is the one per-lane extra this form still asks for, so it sits in the form itself — a disclosure around a single field only hid it. The pipeline picker is gone entirely: every lane is created on `default` (the server's own default) and the template is changed from the lane's own header afterwards. */}
{t("addLaneRouterLabel")}
{( [ { value: "native", label: t("addLaneRouterNativeTool", { tool: aiTools.label(tool) }), }, { 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 || !toolRouterCapable); return ( ); })}
{router !== "native" && ( )}

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

{setupResult && (

{t("addLaneSetupTitle")}

{( [ { label: t("addLaneSetupProfile"), ok: setupResult.profile !== "failed", skipped: setupResult.profile === "skipped", }, { 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)} />
); }