Files
AgentHub/client/src/components/lanes/AddLaneModal.tsx
T
nntrivi2001 e31d261fd7 fix(lanes): don't auto-close Add Lane modal after setup summary
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.
2026-08-06 13:55:43 +07:00

260 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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>
);
}