/** * @file AddLaneModal.tsx * @description The "+ Add lane" flow: pick a SOURCE repo (not a folder to * adopt), pick which of its branches to fork from, name the feature, and the * dashboard provisions a managed git worktree via `POST /api/lanes/worktree` * — the dashboard invents the lane's own directory and branch name, the same * way Shipyard's "+ Add lane" never asks a human to name a folder. The lane * returned is `status: "provisioning"`; the existing `lane_update` WebSocket * subscription in the Workspace page flips it to idle when the worktree is * actually ready, so this component does not poll. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ConfirmModal } from "../ConfirmModal"; import { CwdAutocomplete } from "../run/RunSetup"; import { api } from "../../lib/api"; import type { CwdSuggestion } from "../../lib/api"; import type { Lane } from "../../lib/types"; export function AddLaneModal({ open, onClose, onAdded, cwdSuggestions, }: { open: boolean; onClose: () => void; /** Called with the newly provisioned (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 [sourceRepo, setSourceRepo] = useState(""); const [title, setTitle] = useState(""); const [branches, setBranches] = useState(null); const [base, setBase] = useState(""); const [branchesError, setBranchesError] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [setupResult, setSetupResult] = useState<{ profile: "scaffolded" | "skipped" | "failed"; agents: "ok" | "failed"; mcp: "ok" | "failed"; } | null>(null); const reset = () => { setSourceRepo(""); setTitle(""); setBranches(null); setBase(""); setBranchesError(null); setError(null); setBusy(false); setSetupResult(null); }; // The setup summary (below) is shown for a few seconds before the modal // auto-closes, so the user actually sees whether profile/agents/mcp // succeeded instead of the modal vanishing the instant the lane exists. const closeTimerRef = useRef(null); const finishAndClose = useCallback(() => { if (closeTimerRef.current !== null) { window.clearTimeout(closeTimerRef.current); closeTimerRef.current = null; } reset(); onClose(); }, [onClose]); useEffect( () => () => { if (closeTimerRef.current !== null) window.clearTimeout(closeTimerRef.current); }, [] ); // 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. const lookedUpFor = useRef(""); useEffect(() => { 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); }, [sourceRepo, t]); const submit = async () => { const repo = sourceRepo.trim(); const name = title.trim(); if (!repo || !branches || !name) return; setBusy(true); setError(null); try { const result = await api.lanes.worktree({ sourceRepo: repo, title: name, base: base || undefined, }); 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, }); } setBusy(false); onAdded(result.lane); closeTimerRef.current = window.setTimeout(finishAndClose, 3000); } 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(() => { finishAndClose(); }, [finishAndClose]); return (

{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" />
{branches && (
{branches.length === 0 ? (

{t("addLaneNoBranches")}

) : ( )}
)} {branchesError && !branches && (

{branchesError}

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

)}
); }