e31d261fd7
The 3s auto-close timer closed before a user reasonably had time to look at the setup results, making the feature appear to do nothing. Require an explicit dismiss (Cancel/X) instead.
260 lines
9.3 KiB
TypeScript
260 lines
9.3 KiB
TypeScript
/**
|
||
* @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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
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<string[] | null>(null);
|
||
const [base, setBase] = useState("");
|
||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(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);
|
||
};
|
||
|
||
// 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<string>("");
|
||
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,
|
||
});
|
||
}
|
||
|
||
// 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);
|
||
onAdded(result.lane);
|
||
} 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(() => {
|
||
reset();
|
||
onClose();
|
||
}, [onClose]);
|
||
|
||
return (
|
||
<ConfirmModal
|
||
open={open}
|
||
title={t("addLane")}
|
||
confirmLabel={t("add")}
|
||
cancelLabel={t("destructive.cancel")}
|
||
destructive={false}
|
||
busy={busy}
|
||
disabled={!!setupResult || !sourceRepo.trim() || !title.trim() || !branches}
|
||
onConfirm={submit}
|
||
onCancel={handleCancel}
|
||
>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||
{t("addLaneRepoLabel")}
|
||
</label>
|
||
<CwdAutocomplete
|
||
inputId="add-lane-repo"
|
||
value={sourceRepo}
|
||
onChange={setSourceRepo}
|
||
suggestions={cwdSuggestions}
|
||
/>
|
||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneRepoHint")}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
|
||
{t("addLaneTitleLabel")}
|
||
</label>
|
||
<input
|
||
id="add-lane-title"
|
||
value={title}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
|
||
{branches && (
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||
{t("addLaneBaseLabel")}
|
||
</label>
|
||
{branches.length === 0 ? (
|
||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||
) : (
|
||
<select
|
||
id="add-lane-base"
|
||
value={base}
|
||
onChange={(e) => setBase(e.target.value)}
|
||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||
>
|
||
{branches.map((b) => (
|
||
<option key={b} value={b}>
|
||
{b}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
)}
|
||
{branchesError && !branches && (
|
||
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
||
)}
|
||
|
||
{setupResult && (
|
||
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
|
||
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
|
||
{(
|
||
[
|
||
{
|
||
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) => (
|
||
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
|
||
<span
|
||
className={
|
||
"skipped" in row && row.skipped
|
||
? "text-fg-muted"
|
||
: row.ok
|
||
? "text-status-success"
|
||
: "text-status-danger"
|
||
}
|
||
>
|
||
{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"}
|
||
</span>
|
||
{row.label}
|
||
</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<p role="alert" className="text-xs text-status-danger">
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</ConfirmModal>
|
||
);
|
||
}
|