/** * @file AddLaneModal.tsx * @description The "+ Add lane" flow, in one of three modes chosen with a * segmented toggle — "Repo" by default; a branch prefill (the git bar's "new * workspace from this branch") opens on "Worktree": "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; "Clone" runs `POST /api/lanes/clone` (an * https/ssh URL cloned into a folder of the user's, with their OWN SSH key or * HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to * Settings → Git). There is no AI tool picker: the lane is created with the * default AI tool (Settings → AI Tools), and each new session picks its own * AI on the workspace home. Either * mode's path field can be filled by typing, by the CwdAutocomplete * suggestions, or by Browse (`PathPickButton`: the OS's own folder picker * opened by the server, the in-app `FolderBrowseModal` where it cannot be) — * which a click into the field opens too. * 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, useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; import { useDialog } from "../../hooks/useDialog"; import { CwdAutocomplete } from "../run/RunSetup"; import { PathPickButton } from "../PathPickButton"; import { api } from "../../lib/api"; import type { CwdSuggestion } from "../../lib/api"; import type { Lane, LaneWorktree } from "../../lib/types"; import { useAiTools } from "../../hooks/useAiTools"; import { readDefaultTool } from "../../lib/sessionDefaults"; /** 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)); } } /** The folder `git clone ` would make: the last path segment, no `.git`. */ export function cloneDirName(url: string): string { return ( url .trim() .replace(/[/:]+$/, "") .split(/[/:]/) .pop() || "" ).replace(/\.git$/i, ""); } /** 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" | "clone">("repo"); // Clone mode: the URL, the folder name (blank = the URL's), and — once the // server asked for one — a login for the URL's https host. const [cloneUrl, setCloneUrl] = useState(""); const [cloneName, setCloneName] = useState(""); const [login, setLogin] = useState<{ host: string; username: string; token: string; remember: boolean; } | null>(null); const [sshRefused, setSshRefused] = useState(false); 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(""); const [branchesError, setBranchesError] = useState(null); // No picker: a workspace's tool is only its DEFAULT — each new session // picks its own AI on the workspace home. This is the default AI tool // (Settings → AI Tools), the account's own pick first while it is // installed and on. const aiTools = useAiTools(); const ownTool = readDefaultTool(); const tool = (ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled) ? ownTool : null) ?? aiTools.defaultId ?? "claude"; const noTools = aiTools.loaded && aiTools.defaultId === null; const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [setupResult, setSetupResult] = useState<{ profile: "scaffolded" | "skipped" | "failed"; mcp: "ok" | "failed"; } | null>(null); const reset = () => { setMode("repo"); setCloneUrl(""); setCloneName(""); setLogin(null); setSshRefused(false); setSourceRepo(""); setTitle(""); setBranch(""); setBranches(null); setWorktrees([]); setBranchMode("new"); setBase(""); 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) { // A branch only means something to a worktree. setMode("worktree"); setBranch(prefill.branch); setBranchMode("existing"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [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]); const submit = async () => { const repo = sourceRepo.trim(); const name = title.trim(); if (!repo) return; setBusy(true); setError(null); if (mode === "clone") { const url = cloneUrl.trim(); if (!url) return; setSshRefused(false); try { const result = await api.lanes.clone({ url, parent: repo, name: cloneName.trim() || undefined, title: name || undefined, tool, ...(login?.username.trim() && login.token ? { credentials: { username: login.username.trim(), password: login.token, remember: login.remember, }, } : {}), }); onAdded(result.lane); reset(); onClose(); } catch (err) { const e = err as { code?: string; auth?: { host?: string }; message?: string }; const host = e.auth?.host || url; if (e.code === "ECREDENTIALS") { setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host })); setLogin((l) => ({ host, username: l?.username ?? "", token: "", remember: l?.remember ?? true, })); } else if (e.code === "ESSHAUTH") { setSshRefused(true); setError(t("clone.sshRefused", { host })); } else { setError(err instanceof Error ? err.message : String(err)); } setBusy(false); } return; } 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; } 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] = await Promise.allSettled([ api.lanes.profileInit(result.lane.id), api.lanes.mcpSync(result.lane.id), ]); const outcome = { profile: profileOutcome.status === "fulfilled" ? profileOutcome.value.scaffolded ? "scaffolded" : "skipped" : "failed", mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed", } 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, }); } // 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); } }; // Escape, the backdrop, the X and Cancel all land here. A folder browser // open on top answers its own Escape and backdrop (PathPickButton). const handleCancel = useCallback(() => { reset(); onClose(); }, [onClose]); // Cancel takes focus on open, the safer default. const titleId = useId(); const panelRef = useRef(null); const cancelRef = useRef(null); useDialog(open, panelRef, handleCancel, cancelRef); // 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 === "clone" && (!cloneUrl.trim() || (!!login && (!login.username.trim() || !login.token)))) || (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)); if (!open) return null; // Its own shell, not ConfirmModal: that one lays its body beside the title in // the X button's row, which left this form narrower than its own footer. return (
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby={titleId} data-tour="add-lane" >

{t("addLane")}

setMode("repo")} /> setMode("worktree")} /> setMode("clone")} />
{mode === "clone" && (
{ setCloneUrl(e.target.value); // A login belongs to the host it was asked for. setLogin(null); setSshRefused(false); }} placeholder="https://github.com/owner/repo.git" spellCheck={false} autoComplete="off" 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("clone.urlHint")}

)} {noTools && (

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

)}
{/* A click into the path only focuses it — the picker opens from Browse alone (#77). */}

{mode === "clone" ? t("clone.parentHint", { path: `${sourceRepo.trim().replace(/[\\/]+$/, "") || "…"}/${ cloneName.trim() || cloneDirName(cloneUrl) || "…" }`, }) : mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}

{mode === "clone" && (
setCloneName(e.target.value)} placeholder={cloneDirName(cloneUrl)} spellCheck={false} 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" />
)} {mode === "clone" && login && (
setLogin({ ...login, username: e.target.value })} className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" /> setLogin({ ...login, token: e.target.value })} className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" />
)} {/* 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")}

)}
)} {setupResult && (

{t("addLaneSetupTitle")}

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

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

))}
)} {error && (

{error} {sshRefused && ( <> {" "} {t("clone.sshSettings")} )}

)}
); }