diff --git a/apps/web/package.json b/apps/web/package.json
index 2d21902e8..20cd99b1c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -47,7 +47,6 @@
"lucide-react": "^1.27.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "sonner": "^2.0.7",
"yjs": "^13.6.32"
},
"devDependencies": {
diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx
index 94e124de3..107d31aed 100644
--- a/apps/web/src/app.tsx
+++ b/apps/web/src/app.tsx
@@ -7,7 +7,6 @@ import { BootScreen, Button, CorbitsMark, EmptyState } from "@corbits/react-ui";
import { QueryClientProvider } from "@tanstack/react-query";
import { CircleAlert } from "lucide-react";
import { useEffect, useMemo } from "react";
-import { Toaster } from "sonner";
import { AuthScreen } from "./auth-screen";
import { BenchProvider } from "./bench-context";
@@ -121,7 +120,6 @@ function Shell({
)}
-
diff --git a/apps/web/test/toast-single-system.test.tsx b/apps/web/test/toast-single-system.test.tsx
new file mode 100644
index 000000000..38202463c
--- /dev/null
+++ b/apps/web/test/toast-single-system.test.tsx
@@ -0,0 +1,196 @@
+// CL-6372: a failed workbench create used to fire two toasts — the house
+// `` from `@corbits/react-ui` (mounted in main.tsx) and a second,
+// unstyled `` imported straight from `sonner` (mounted in
+// app.tsx). Sonner's `toast()` renders into every mounted ``, so
+// one `toast()` call rendered twice: the tokened grey box bottom-center from
+// react-ui, and a default white pill bottom-right from the raw sonner
+// mount. app.tsx no longer imports `sonner` at all — this file pins that a
+// single `` mount produces exactly one toast per call, carries
+// the house styling, and clears itself.
+
+import { toast, Toaster } from "@corbits/react-ui";
+import { afterEach, describe, expect, test } from "bun:test";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+
+import { BenchProvider } from "../src/bench-context";
+import { NavigationProvider } from "../src/navigation";
+import { NewWorkbenchPickerRoute } from "../src/pages/new-workbench-picker";
+import { TestQueryProvider } from "./test-query-provider";
+
+const realFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+function json(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+const MEMBERSHIP = {
+ data: [
+ {
+ principalId: "prn_1",
+ tenantId: "tnt_1",
+ tenantName: "Corbits Bench",
+ tenantSlug: "corbits-bench",
+ kind: "user",
+ status: "active",
+ roles: [],
+ },
+ ],
+ nextCursor: null,
+};
+
+function stubFailingCreate(): void {
+ globalThis.fetch = ((input: RequestInfo | URL) => {
+ const path = typeof input === "string" ? input : String(input);
+ if (path.includes("/api/me/principals")) {
+ return Promise.resolve(json(MEMBERSHIP));
+ }
+ if (path.includes("/workflows/definitions")) {
+ return Promise.resolve(json({ data: [], nextCursor: null }));
+ }
+ return Promise.resolve(json({ error: "boom" }, 500));
+ }) as typeof fetch;
+}
+
+let container: HTMLDivElement | null = null;
+let root: Root | null = null;
+
+afterEach(() => {
+ if (root !== null) {
+ act(() => root?.unmount());
+ root = null;
+ }
+ if (container !== null) {
+ container.remove();
+ container = null;
+ }
+ document
+ .querySelectorAll("[data-sonner-toaster]")
+ .forEach((node) => node.remove());
+});
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+const settle = () => act(() => sleep(10));
+
+function visibleToasts(): NodeListOf {
+ return document.body.querySelectorAll("[data-sonner-toast]");
+}
+
+// Sonner keeps its toast store globally, independent of any one `` mount — an un-dismissed toast from one test reappears the moment the
+// next test's fresh Toaster subscribes. Every test that leaves a toast
+// showing waits out the full display duration before finishing.
+async function waitForClear(): Promise {
+ await act(async () => {
+ await sleep(2400);
+ });
+}
+
+async function renderPickerWithToaster(): Promise {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ await act(async () => {
+ root?.render(
+
+ undefined}>
+
+
+
+
+
+ ,
+ );
+ });
+ for (let i = 0; i < 20; i++) {
+ await settle();
+ if (container.querySelector('[role="radiogroup"]') !== null) break;
+ }
+}
+
+describe("the one toast system (CL-6372)", () => {
+ test("a failed workbench create fires exactly one toast", async () => {
+ stubFailingCreate();
+ await renderPickerWithToaster();
+
+ const createButton = Array.from(
+ container?.querySelectorAll("button") ?? [],
+ ).find((button) => button.textContent === "Create workbench");
+ await act(async () => {
+ createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ for (let i = 0; i < 30; i++) {
+ await settle();
+ if (visibleToasts().length > 0) break;
+ }
+
+ const shown = visibleToasts();
+ expect(shown.length).toBe(1);
+ expect(shown[0]?.textContent).toBe(
+ "Couldn't create the workbench — try again.",
+ );
+ await waitForClear();
+ });
+
+ test("the failure toast carries the house styling, not sonner's default", async () => {
+ stubFailingCreate();
+ await renderPickerWithToaster();
+
+ const createButton = Array.from(
+ container?.querySelectorAll("button") ?? [],
+ ).find((button) => button.textContent === "Create workbench");
+ await act(async () => {
+ createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ for (let i = 0; i < 30; i++) {
+ await settle();
+ if (visibleToasts().length > 0) break;
+ }
+
+ const shown = visibleToasts()[0];
+ expect(shown?.classList.contains("corbits-toast")).toBe(true);
+ expect(shown?.getAttribute("data-styled")).toBe("false");
+ const region = document.body.querySelector("[data-sonner-toaster]");
+ expect(region?.getAttribute("data-y-position")).toBe("bottom");
+ expect(region?.getAttribute("data-x-position")).toBe("center");
+ await waitForClear();
+ });
+
+ test("a second toast dismisses the first instead of stacking", async () => {
+ stubFailingCreate();
+ await renderPickerWithToaster();
+
+ act(() => toast("First"));
+ await settle();
+ act(() => toast("Second"));
+ await settle();
+
+ const staying = document.body.querySelectorAll(
+ '[data-sonner-toast][data-removed="false"]',
+ );
+ expect(staying.length).toBe(1);
+ expect(staying[0]?.textContent).toBe("Second");
+ await waitForClear();
+ });
+
+ test("the toast dismisses itself after its display duration", async () => {
+ stubFailingCreate();
+ await renderPickerWithToaster();
+
+ act(() => toast("Grant revoked"));
+ await settle();
+ expect(visibleToasts().length).toBe(1);
+
+ await act(async () => {
+ await sleep(2400);
+ });
+ expect(visibleToasts().length).toBe(0);
+ });
+});
diff --git a/bun.lock b/bun.lock
index 52d16c2aa..9c4c6b5dc 100644
--- a/bun.lock
+++ b/bun.lock
@@ -151,7 +151,6 @@
"lucide-react": "^1.27.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "sonner": "^2.0.7",
"yjs": "^13.6.32",
},
"devDependencies": {
@@ -3374,6 +3373,8 @@
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="],
+ "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#caa5214", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-caa5214", "sha512-z8DRBFgA4ukM8p29COeaMjfKZYe5jAUF4OBMiaIQFuW592+DGD/y6Ws6SjGlXmR9azkHNWh8oTzjlWlRP24vsQ=="],
+
"@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="],
"ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],