Skip to content
Open
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
11 changes: 10 additions & 1 deletion packages/web/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,17 @@ import { QK } from "./keys";

// ─── Helpers ──────────────────────────────────────────────────────────────────

function formatApiError(e: unknown): string {
if (typeof e === "object" && e !== null && "detail" in e && typeof e.detail === "string") {
const detail = e.detail.trim();
if (detail) return detail;
}
if (typeof e === "object") return JSON.stringify(e);
return String(e);
}

function err(e: unknown): never {
throw new Error(typeof e === "object" ? JSON.stringify(e) : String(e));
throw new Error(formatApiError(e));
}

// ─── Workspaces ──────────────────────────────────────────────────────────────
Expand Down
7 changes: 7 additions & 0 deletions packages/web/src/components/shared/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface ConfirmDialogProps {
onCancel: () => void;
danger?: boolean;
loading?: boolean;
error?: string;
}

export function ConfirmDialog({
Expand All @@ -29,6 +30,7 @@ export function ConfirmDialog({
onCancel,
danger = true,
loading = false,
error,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onCancel()}>
Expand All @@ -51,6 +53,11 @@ export function ConfirmDialog({
<DialogDescription className="mt-1">{description}</DialogDescription>
</div>
</div>
{error ? (
<p role="alert" className="text-sm mb-1" style={{ color: COLOR.destructive }}>
{error}
</p>
) : null}
<DialogFooter>
<Button variant="surface" size="sm" onClick={onCancel}>
Cancel
Expand Down
27 changes: 24 additions & 3 deletions packages/web/src/components/workspaces/WorkspaceDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ const NAV_SECTIONS = [
},
] as const;

const WORKSPACE_DELETE_FAILED = "Could not delete workspace. Try again.";

export function WorkspaceDetail() {
const { mask } = useDemo();
const { showMetadata } = useMetadata();
Expand All @@ -76,10 +78,24 @@ export function WorkspaceDetail() {
const [sessionsExpanded, setSessionsExpanded] = useState(false);

const handleDelete = async () => {
await deleteWorkspace.mutateAsync(workspaceId);
try {
await deleteWorkspace.mutateAsync(workspaceId);
} catch {
return;
}
navigate({ to: "/workspaces" as never });
};

const openDelete = () => {
deleteWorkspace.reset();
setConfirmDelete(true);
};

const cancelDelete = () => {
deleteWorkspace.reset();
setConfirmDelete(false);
};

return (
<div className="page-container page-container--wide">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
Expand All @@ -98,7 +114,7 @@ export function WorkspaceDetail() {
<Zap className="w-3.5 h-3.5" strokeWidth={2} />
Schedule Dream
</Button>
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}>
<Button variant="destructive" size="sm" onClick={openDelete}>
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
Delete
</Button>
Expand Down Expand Up @@ -344,8 +360,13 @@ export function WorkspaceDetail() {
description={`This will permanently delete workspace "${mask(workspaceId)}" and all its data. This cannot be undone.`}
confirmLabel="Delete workspace"
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
onCancel={cancelDelete}
loading={deleteWorkspace.isPending}
error={
deleteWorkspace.isError
? deleteWorkspace.error?.message || WORKSPACE_DELETE_FAILED
: undefined
}
/>

<ScheduleDreamModal
Expand Down
204 changes: 204 additions & 0 deletions packages/web/src/test/workspace-delete-error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { DemoProvider } from "@/context/DemoContext";
import { MetadataProvider } from "@/context/MetadataContext";
import { saveStore } from "@/lib/config";
import { routeTree } from "@/routeTree.gen";

const { httpFetch } = vi.hoisted(() => ({ httpFetch: vi.fn() }));
vi.mock("@/lib/http", () => ({ httpFetch }));

const WORKSPACE_ID = "ws-alpha";
const CONFLICT_DETAIL =
"Cannot delete workspace 'ws-alpha': active session(s) remain. Delete all sessions first.";
const INSTANCE = {
id: "inst-1",
name: "Local",
baseUrl: "http://localhost:8000",
token: "secret-token",
};

function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}

function requestOf(input: Request | string, init?: RequestInit) {
return typeof input === "string" ? new Request(input, init) : input;
}

function mockHoncho(options: { onDelete?: (req: Request) => Promise<Response> | Response } = {}) {
httpFetch.mockImplementation(async (input: Request | string, init?: RequestInit) => {
const req = requestOf(input, init);
const url = req.url;
if (req.method === "DELETE") {
return options.onDelete ? options.onDelete(req) : json({}, 202);
}
if (url.includes("/queue/status")) {
return json({
in_progress_work_units: 0,
pending_work_units: 0,
completed_work_units: 0,
total_work_units: 0,
});
}
if (url.includes("/v3/workspaces") && !url.includes("list")) {
return json({
id: WORKSPACE_ID,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
});
}
return json({
items: [{ id: WORKSPACE_ID, created_at: "2026-01-01T00:00:00Z" }],
total: 1,
page: 1,
size: 20,
pages: 1,
});
});
}

function renderWorkspace() {
saveStore({ instances: [INSTANCE], activeId: INSTANCE.id });
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: [`/workspaces/${WORKSPACE_ID}`] }),
});
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return {
router,
...render(
<QueryClientProvider client={qc}>
<DemoProvider>
<MetadataProvider>
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
<RouterProvider router={router as any} />
</MetadataProvider>
</DemoProvider>
</QueryClientProvider>,
),
};
}

async function confirmDelete() {
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Delete workspace" }));
return { user, dialog };
}

describe("workspace delete errors", () => {
afterEach(() => {
httpFetch.mockReset();
localStorage.clear();
});

it("shows the 409 detail after confirming deletion", async () => {
mockHoncho({
onDelete: () => json({ detail: CONFLICT_DETAIL }, 409),
});
renderWorkspace();
await confirmDelete();
expect((await screen.findByRole("alert")).textContent).toBe(CONFLICT_DETAIL);
});

it("keeps the confirmation dialog open after a 409", async () => {
mockHoncho({
onDelete: () => json({ detail: CONFLICT_DETAIL }, 409),
});
renderWorkspace();
await confirmDelete();
await screen.findByRole("alert");
expect(screen.getByRole("dialog")).toHaveAccessibleName("Delete workspace");
});

it("leaves the confirm button usable after a failed deletion", async () => {
mockHoncho({
onDelete: () => json({ detail: CONFLICT_DETAIL }, 409),
});
renderWorkspace();
const { dialog } = await confirmDelete();
await screen.findByRole("alert");
expect(within(dialog).getByRole("button", { name: "Delete workspace" })).toBeEnabled();
});

it("retries deletion from the open dialog after a 409", async () => {
let deletes = 0;
mockHoncho({
onDelete: () => {
deletes += 1;
return json({ detail: CONFLICT_DETAIL }, 409);
},
});
renderWorkspace();
const { user, dialog } = await confirmDelete();
await screen.findByRole("alert");
await user.click(within(dialog).getByRole("button", { name: "Delete workspace" }));
await waitFor(() => {
expect(deletes).toBe(2);
});
});

it("does not keep a stale error after cancel and reopen", async () => {
mockHoncho({
onDelete: () => json({ detail: CONFLICT_DETAIL }, 409),
});
renderWorkspace();
const { user, dialog } = await confirmDelete();
await screen.findByRole("alert");
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
await user.click(await screen.findByRole("button", { name: "Delete" }));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});

it("shows a fallback message when deletion fails without an API detail", async () => {
mockHoncho({
onDelete: () => Promise.reject(new Error("Network request failed")),
});
renderWorkspace();
await confirmDelete();
expect((await screen.findByRole("alert")).textContent).toBe("Network request failed");
});

it("navigates to the workspace list after a successful deletion", async () => {
mockHoncho();
const { router } = renderWorkspace();
await confirmDelete();
await waitFor(() => {
expect(router.state.location.pathname).toBe("/workspaces");
});
});

it("does not render an alert when ConfirmDialog has no error", () => {
render(
<ConfirmDialog
open
title="Delete webhook"
description="This endpoint will stop receiving events immediately."
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});

it("does not put the instance token in the delete dialog", async () => {
mockHoncho({
onDelete: () => json({ detail: CONFLICT_DETAIL }, 409),
});
renderWorkspace();
await confirmDelete();
await screen.findByRole("alert");
expect(screen.getByRole("dialog").textContent).not.toContain(INSTANCE.token);
});
});