diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index 8c0a594..9caef82 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -58,6 +58,25 @@ export function AddLaneModal({ 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. @@ -128,9 +147,9 @@ export function AddLaneModal({ }); } - reset(); + setBusy(false); onAdded(result.lane); - onClose(); + closeTimerRef.current = window.setTimeout(finishAndClose, 3000); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setBusy(false); @@ -144,9 +163,8 @@ export function AddLaneModal({ // 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]); + finishAndClose(); + }, [finishAndClose]); return ( @@ -214,6 +232,38 @@ export function AddLaneModal({

{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} diff --git a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx index 27c8897..9466f80 100644 --- a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx +++ b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx @@ -158,7 +158,8 @@ describe("AddLaneModal", () => { expect(onAdded).toHaveBeenCalledWith( expect.objectContaining({ id: 9, status: "provisioning" }) ); - expect(onClose).toHaveBeenCalled(); + // The setup summary stays on screen for a few seconds before auto-closing. + await waitFor(() => expect(onClose).toHaveBeenCalled(), { timeout: 4000 }); }); it("shows a server error and leaves the modal open instead of closing silently", async () => { @@ -228,6 +229,31 @@ describe("AddLaneModal", () => { await user.click(screen.getByRole("button", { name: "Add lane" })); await waitFor(() => expect(onAdded).toHaveBeenCalled()); + await waitFor(() => expect(onClose).toHaveBeenCalled(), { timeout: 4000 }); + }); + + it("shows the setup summary and lets the user dismiss it early instead of waiting out the auto-close timer", async () => { + vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); + vi.mocked(api.lanes.worktree).mockResolvedValue({ + lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane, + }); + const onClose = vi.fn(); + renderModal({ onClose }); + const user = userEvent.setup(); + + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, "/Users/tester/projects/repo"); + await screen.findByLabelText("Branch to fork from"); + await user.type(screen.getByLabelText("Title"), "demo3"); + await user.click(screen.getByRole("button", { name: "Add lane" })); + + expect(await screen.findByText("Setup")).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + + const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" }); + if (!dismissButton) throw new Error("Cancel button not found"); + await user.click(dismissButton); expect(onClose).toHaveBeenCalled(); }); }); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 3ec93c3..0f46486 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -23,6 +23,10 @@ "addLaneNotARepo": "Not a git repository (or no read access) yet.", "addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.", "addLaneRepoLabel": "Source repository", + "addLaneSetupAgents": "Agents", + "addLaneSetupMcp": "MCP servers", + "addLaneSetupProfile": "Profile", + "addLaneSetupTitle": "Setup", "addLaneTitleLabel": "Title", "addLaneTitlePlaceholder": "Optional", "autoStage": "auto: {{stage}}", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index c58f7e5..45ecb66 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -23,6 +23,10 @@ "addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).", "addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.", "addLaneRepoLabel": "Repo nguồn", + "addLaneSetupAgents": "Agent", + "addLaneSetupMcp": "MCP server", + "addLaneSetupProfile": "Profile", + "addLaneSetupTitle": "Thiết lập", "addLaneTitleLabel": "Tiêu đề", "addLaneTitlePlaceholder": "Không bắt buộc", "autoStage": "tự động: {{stage}}", diff --git a/docs/LANES.md b/docs/LANES.md index d102d4a..6a58b12 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -32,7 +32,7 @@ ccam lanes add --repo /path/to/repo --title "My Feature" --base main --slug my-f `--title`, `--base`, and `--slug` are optional. The CLI waits for background provisioning to finish and reports either the ready lane or its failure notes. -Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `ccam lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `ccam lanes agents install`, and `ccam lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply. +Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `ccam lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `ccam lanes agents install`, and `ccam lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results for a few seconds (or until dismissed) before closing. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply. ## Destructive lane actions