Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions frontend/src/renderer/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-125 -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-surface p-4 shadow-lg">
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<Dialog.Title className="text-sm font-semibold text-foreground">{title}</Dialog.Title>
<Dialog.Description asChild>
<div className="mt-2">{description}</div>
</Dialog.Description>
</div>
</div>
{error && (
<div className="mt-3 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive">
<XCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<span>{error}</span>
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={busy} size={size}>
Cancel
</Button>
<Button
className={
destructive
? "border-destructive bg-destructive text-destructive-foreground font-medium hover:opacity-90"
: ""
}
onClick={onConfirm}
disabled={busy}
size={size}
>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
54 changes: 46 additions & 8 deletions frontend/src/renderer/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,39 +140,64 @@ beforeEach(() => {
navigateMock.mockReset();
renameSessionMock.mockReset().mockResolvedValue(undefined);
mockParams.projectId = undefined;
vi.spyOn(window, "confirm").mockReturnValue(true);
vi.spyOn(window, "alert").mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
});

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();

Expand Down Expand Up @@ -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] };
Expand Down
41 changes: 30 additions & 11 deletions frontend/src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -434,6 +435,7 @@ function ProjectItem({
const queryClient = useQueryClient();
const [removeError, setRemoveError] = useState<string | null>(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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -598,11 +599,6 @@ function ProjectItem({
</DropdownMenuContent>
</DropdownMenu>
</div>
{removeError && (
<span className="sr-only" role="status">
{removeError}
</span>
)}
{/* project-sidebar__sessions: indented under the project parent so worker
sessions read as children without adding a persistent guide rail. */}
{expanded && sessions.length > 0 && (
Expand All @@ -617,6 +613,29 @@ function ProjectItem({
))}
</SidebarMenuSub>
)}
<ConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
if (!isRemoving) setConfirmOpen(open);
}}
title={`Remove project`}
description={
<>
<p className="text-sm font-medium text-foreground">
This will remove <strong>{workspace.name}</strong> from AO
</p>
<p className="mt-1 text-xs text-muted-foreground">
This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored
history on disk.
</p>
</>
}
confirmLabel={isRemoving ? "Removing…" : "Remove"}
destructive
busy={isRemoving}
error={removeError}
onConfirm={handleConfirmRemove}
/>
</SidebarMenuItem>
);
}
Expand Down