feat(lanes): show Add Lane auto-setup summary before closing modal
The three setup calls (profile/agents/mcp) fired after worktree creation but their outcome was only logged to the console. Keep the modal open with a ✓/✗ summary for a few seconds (or until dismissed) so the user actually sees what happened, matching the original F5 design.
This commit is contained in:
@@ -58,6 +58,25 @@ export function AddLaneModal({
|
|||||||
setSetupResult(null);
|
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<number | null>(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
|
// 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
|
// keystroke while typing a path doesn't fire a request against a path that
|
||||||
// isn't finished yet.
|
// isn't finished yet.
|
||||||
@@ -128,9 +147,9 @@ export function AddLaneModal({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reset();
|
setBusy(false);
|
||||||
onAdded(result.lane);
|
onAdded(result.lane);
|
||||||
onClose();
|
closeTimerRef.current = window.setTimeout(finishAndClose, 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -144,9 +163,8 @@ export function AddLaneModal({
|
|||||||
// into after the very first character. useCallback keeps the identity stable
|
// into after the very first character. useCallback keeps the identity stable
|
||||||
// across renders so only mount/unmount (and a real onClose change) refocuses.
|
// across renders so only mount/unmount (and a real onClose change) refocuses.
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
reset();
|
finishAndClose();
|
||||||
onClose();
|
}, [finishAndClose]);
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
@@ -156,7 +174,7 @@ export function AddLaneModal({
|
|||||||
cancelLabel={t("destructive.cancel")}
|
cancelLabel={t("destructive.cancel")}
|
||||||
destructive={false}
|
destructive={false}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
disabled={!sourceRepo.trim() || !title.trim() || !branches}
|
disabled={!!setupResult || !sourceRepo.trim() || !title.trim() || !branches}
|
||||||
onConfirm={submit}
|
onConfirm={submit}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
>
|
>
|
||||||
@@ -214,6 +232,38 @@ export function AddLaneModal({
|
|||||||
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
<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 && (
|
{error && (
|
||||||
<p role="alert" className="text-xs text-status-danger">
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ describe("AddLaneModal", () => {
|
|||||||
expect(onAdded).toHaveBeenCalledWith(
|
expect(onAdded).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ id: 9, status: "provisioning" })
|
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 () => {
|
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 user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
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();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
|
"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.",
|
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
|
||||||
"addLaneRepoLabel": "Source repository",
|
"addLaneRepoLabel": "Source repository",
|
||||||
|
"addLaneSetupAgents": "Agents",
|
||||||
|
"addLaneSetupMcp": "MCP servers",
|
||||||
|
"addLaneSetupProfile": "Profile",
|
||||||
|
"addLaneSetupTitle": "Setup",
|
||||||
"addLaneTitleLabel": "Title",
|
"addLaneTitleLabel": "Title",
|
||||||
"addLaneTitlePlaceholder": "Optional",
|
"addLaneTitlePlaceholder": "Optional",
|
||||||
"autoStage": "auto: {{stage}}",
|
"autoStage": "auto: {{stage}}",
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
|
"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.",
|
"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",
|
"addLaneRepoLabel": "Repo nguồn",
|
||||||
|
"addLaneSetupAgents": "Agent",
|
||||||
|
"addLaneSetupMcp": "MCP server",
|
||||||
|
"addLaneSetupProfile": "Profile",
|
||||||
|
"addLaneSetupTitle": "Thiết lập",
|
||||||
"addLaneTitleLabel": "Tiêu đề",
|
"addLaneTitleLabel": "Tiêu đề",
|
||||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||||
"autoStage": "tự động: {{stage}}",
|
"autoStage": "tự động: {{stage}}",
|
||||||
|
|||||||
+1
-1
@@ -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.
|
`--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
|
## Destructive lane actions
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user