diff --git a/frontend/src/renderer/components/ConfirmDialog.tsx b/frontend/src/renderer/components/ConfirmDialog.tsx new file mode 100644 index 0000000000..4b7b23b4d0 --- /dev/null +++ b/frontend/src/renderer/components/ConfirmDialog.tsx @@ -0,0 +1,71 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { Loader2, XCircle } from "lucide-react"; +import { Button } from "./ui/button"; + +type ConfirmDialogProps = { + open: boolean; + title: string; + description: React.ReactNode; + confirmLabel: string; + destructive?: boolean; + busy?: boolean; + error?: string | null; + onConfirm: () => void; + onOpenChange: (open: boolean) => void; + size?: "default" | "sm"; +}; + +export function ConfirmDialog({ + open, + title, + description, + confirmLabel, + destructive, + busy, + error, + onConfirm, + onOpenChange, + size = "default", +}: ConfirmDialogProps) { + return ( + + + + +
+
+ {title} + +
{description}
+
+
+
+ {error && ( +
+
+ )} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx index 8228ce69dd..0320de1797 100644 --- a/frontend/src/renderer/components/Sidebar.test.tsx +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -140,8 +140,6 @@ beforeEach(() => { navigateMock.mockReset(); renameSessionMock.mockReset().mockResolvedValue(undefined); mockParams.projectId = undefined; - vi.spyOn(window, "confirm").mockReturnValue(true); - vi.spyOn(window, "alert").mockImplementation(() => undefined); }); afterEach(() => { @@ -149,30 +147,57 @@ afterEach(() => { }); describe("Sidebar", () => { - it("confirms project removal before calling the remove handler", async () => { + it("shows a ConfirmDialog and calls onRemoveProject when confirmed", async () => { const user = userEvent.setup(); const onRemoveProject = renderSidebar(); await user.click(screen.getByLabelText("Project actions for Project One")); await user.click(await screen.findByRole("menuitem", { name: "Remove project" })); - expect(window.confirm).toHaveBeenCalledWith( - "Remove project Project One? This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored history on disk.", - ); + // The ConfirmDialog renders via Radix Portal — find it by role + const dialog = await screen.findByRole("dialog", { name: "Remove project" }); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveTextContent("Project One"); + + await user.click(screen.getByRole("button", { name: "Remove" })); await waitFor(() => expect(onRemoveProject).toHaveBeenCalledTimes(1)); }); - it("does not remove the project when confirmation is cancelled", async () => { - vi.mocked(window.confirm).mockReturnValue(false); + it("does not remove the project when cancellation is clicked in the ConfirmDialog", async () => { const user = userEvent.setup(); const onRemoveProject = renderSidebar(); await user.click(screen.getByLabelText("Project actions for Project One")); await user.click(await screen.findByRole("menuitem", { name: "Remove project" })); + await screen.findByRole("dialog", { name: "Remove project" }); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + // Dialog should close and the handler must not have fired + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Remove project" })).not.toBeInTheDocument(), + ); expect(onRemoveProject).not.toHaveBeenCalled(); }); + it("shows an error message inside the ConfirmDialog when removal fails", async () => { + const user = userEvent.setup(); + const onRemoveProject = vi + .fn() + .mockRejectedValueOnce(new Error("Failed to remove project")) as RemoveProjectHandler; + renderSidebar({ onRemoveProject }); + + await user.click(screen.getByLabelText("Project actions for Project One")); + await user.click(await screen.findByRole("menuitem", { name: "Remove project" })); + await screen.findByRole("dialog", { name: "Remove project" }); + await user.click(screen.getByRole("button", { name: "Remove" })); + + // The error text renders inside the dialog — find it by its destructive color class + expect(await screen.findByText("Failed to remove project")).toBeInTheDocument(); + // Dialog stays open on failure so the user can retry or cancel + expect(screen.getByRole("dialog", { name: "Remove project" })).toBeInTheDocument(); + }); + it("reveals dashboard and orchestrator buttons alongside the kebab on the project row", () => { renderSidebar(); @@ -432,6 +457,19 @@ describe("Sidebar", () => { ); }); + it("shows the project name and context in the ConfirmDialog description", async () => { + const user = userEvent.setup(); + renderSidebar(); + + await user.click(screen.getByLabelText("Project actions for Project One")); + await user.click(await screen.findByRole("menuitem", { name: "Remove project" })); + + const dialog = await screen.findByRole("dialog", { name: "Remove project" }); + expect(dialog).toHaveTextContent("Project One"); + expect(dialog).toHaveTextContent("live sessions"); + expect(dialog).toHaveTextContent("repository folder"); + }); + it("renames a session inline and persists via the daemon", async () => { const user = userEvent.setup(); const workspaceWithSession = { ...workspace, sessions: [session] }; diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 49d2f3ef76..9bf5e9fa3d 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -67,6 +67,7 @@ import { cn } from "../lib/utils"; import { useUiStore } from "../stores/ui-store"; import { CreateProjectAgentSheet, type CreateProjectAgentSelection } from "./CreateProjectAgentSheet"; import { Button } from "./ui/button"; +import { ConfirmDialog } from "./ConfirmDialog"; // The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live // in the full-width topbar's left inset (_shell renders the bar above the @@ -434,6 +435,7 @@ function ProjectItem({ const queryClient = useQueryClient(); const [removeError, setRemoveError] = useState(null); const [isRemoving, setIsRemoving] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); const [isSpawning, setIsSpawning] = useState(false); const restartingProjectIds = useUiStore((state) => state.restartingProjectIds); const isProjectRestarting = restartingProjectIds.has(workspace.id); @@ -475,22 +477,21 @@ function ProjectItem({ } }; - const removeProject = async () => { + const removeProject = () => { setRemoveError(null); - const confirmed = window.confirm( - `Remove project ${workspace.name}? This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored history on disk.`, - ); - if (!confirmed) return; + setConfirmOpen(true); + }; + const handleConfirmRemove = async () => { setIsRemoving(true); try { await onRemoveProject(workspace.id); + setConfirmOpen(false); // The route for a removed project no longer resolves; fall back home. if (selection.activeProjectId === workspace.id) selection.goHome(); } catch (err) { const message = err instanceof Error ? err.message : "Could not remove project"; setRemoveError(message); - window.alert(message); } finally { setIsRemoving(false); } @@ -598,11 +599,6 @@ function ProjectItem({ - {removeError && ( - - {removeError} - - )} {/* project-sidebar__sessions: indented under the project parent so worker sessions read as children without adding a persistent guide rail. */} {expanded && sessions.length > 0 && ( @@ -617,6 +613,29 @@ function ProjectItem({ ))} )} + { + if (!isRemoving) setConfirmOpen(open); + }} + title={`Remove project`} + description={ + <> +

+ This will remove {workspace.name} from AO +

+

+ This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored + history on disk. +

+ + } + confirmLabel={isRemoving ? "Removing…" : "Remove"} + destructive + busy={isRemoving} + error={removeError} + onConfirm={handleConfirmRemove} + /> ); }