From fc5334ec8ffe716a7edc3cce19ddb810bc0350bb Mon Sep 17 00:00:00 2001
From: Adam Leith <adamleithp@users.noreply.github.com>
Date: Tue, 28 Jul 2026 19:42:56 +0100
Subject: [PATCH] feat(canvas): delete a canvas with a confirm and an undo
 window
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The canvas "…" menu had no way to delete; the canvases grid had one that
deleted on click with no confirmation.

Both now open an alert dialog, and confirming doesn't delete straight
away: the canvas is marked pending and the host isn't told until the
"Deleted artifact" toast's timer expires, so Undo simply cancels the
timer rather than recreating anything.

Pending canvases stay in their lists — the artifacts row swaps its
template icon for a pulsing trash can and stops opening, the grid card
dims behind the same icon — so undoing restores them in place.

The commit runs outside React (module-level timer + hostClient) because
deleting from inside a canvas navigates away immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---
 packages/shared/src/analytics-events.ts       |   2 +
 .../components/WebsiteChannelArtifacts.tsx    |  53 ++++++--
 .../components/WebsiteDashboardsIndex.tsx     | 108 ++++++++++-----
 .../canvas/components/WebsiteLayout.tsx       |  68 +++++++++-
 .../canvas/deleteCanvasWithUndo.test.ts       | 104 +++++++++++++++
 .../features/canvas/deleteCanvasWithUndo.ts   | 123 ++++++++++++++++++
 .../features/canvas/hooks/useDashboards.ts    |   6 +
 .../canvas/stores/pendingCanvasDeleteStore.ts |  29 +++++
 8 files changed, 452 insertions(+), 41 deletions(-)
 create mode 100644 packages/ui/src/features/canvas/deleteCanvasWithUndo.test.ts
 create mode 100644 packages/ui/src/features/canvas/deleteCanvasWithUndo.ts
 create mode 100644 packages/ui/src/features/canvas/stores/pendingCanvasDeleteStore.ts

diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts
index 83f6edc458..3025d359b8 100644
--- a/packages/shared/src/analytics-events.ts
+++ b/packages/shared/src/analytics-events.ts
@@ -947,6 +947,8 @@ export type DashboardActionType =
   | "open"
   | "create"
   | "delete"
+  /** The delete was undone inside its undo window, so nothing was removed. */
+  | "delete_undo"
   | "rename"
   | "save"
   | "fork"
diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx
index 4ea4783e1c..d8eca72aa9 100644
--- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx
+++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx
@@ -1,4 +1,4 @@
-import { CaretRightIcon } from "@phosphor-icons/react";
+import { CaretRightIcon, TrashIcon } from "@phosphor-icons/react";
 import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas";
 import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas";
 import { formatRelativeTimeShort } from "@posthog/shared";
@@ -9,6 +9,7 @@ import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTe
 import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
 import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks";
 import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards";
+import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore";
 import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact";
 import { useTasks } from "@posthog/ui/features/tasks/useTasks";
 import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
@@ -140,16 +141,13 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) {
           <div className="flex flex-col gap-0.5">
             {items.map((item) =>
               item.kind === "canvas" ? (
-                <ArtifactRow
+                <CanvasArtifactRow
                   key={item.key}
-                  accent="violet"
-                  icon={iconForTemplate(item.templateId, {
-                    size: 15,
-                    className: "text-violet-9",
-                  })}
+                  dashboardId={item.dashboardId}
+                  templateId={item.templateId}
                   title={item.title}
-                  subtitle={`Canvas · ${formatRelativeTimeShort(item.ts)}`}
-                  onClick={() => openCanvas(item.dashboardId)}
+                  ts={item.ts}
+                  onClick={openCanvas}
                 />
               ) : (
                 <PrArtifactRow
@@ -168,6 +166,43 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) {
   );
 }
 
+// A canvas artifact row. While the canvas is inside its delete-undo window the
+// row stays put — its template icon becomes a pulsing trash can and the row
+// stops opening — so undoing puts it back exactly where it was.
+function CanvasArtifactRow({
+  dashboardId,
+  templateId,
+  title,
+  ts,
+  onClick,
+}: {
+  dashboardId: string;
+  templateId: string;
+  title: string;
+  ts: number;
+  onClick: (dashboardId: string) => void;
+}) {
+  const deleting = useIsCanvasPendingDelete(dashboardId);
+
+  return (
+    <ArtifactRow
+      accent={deleting ? "red" : "violet"}
+      icon={
+        deleting ? (
+          <TrashIcon size={15} className="animate-pulse text-red-9" />
+        ) : (
+          iconForTemplate(templateId, { size: 15, className: "text-violet-9" })
+        )
+      }
+      title={title}
+      subtitle={
+        deleting ? "Deleting…" : `Canvas · ${formatRelativeTimeShort(ts)}`
+      }
+      onClick={deleting ? undefined : () => onClick(dashboardId)}
+    />
+  );
+}
+
 // A PR artifact row. The PR's lifecycle state (open / draft / merged / closed)
 // comes from usePrArtifact, which also gates the URL — PR links come from run
 // output, so a row must not fetch from whatever host that names.
diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx
index e4f359ead2..802081f82a 100644
--- a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx
+++ b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx
@@ -1,6 +1,13 @@
 import { DotsThreeIcon, LinkIcon, TrashIcon } from "@phosphor-icons/react";
 import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas";
 import {
+  AlertDialog,
+  AlertDialogClose,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
   Badge,
   Button,
   Card,
@@ -15,6 +22,7 @@ import {
 import { formatRelativeTimeShort } from "@posthog/shared";
 import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
 import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu";
+import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo";
 import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas";
 import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge";
 import { useCanvasTemplates } from "@posthog/ui/features/canvas/hooks/useCanvasTemplates";
@@ -22,9 +30,9 @@ import {
   useDashboardMutations,
   useDashboards,
 } from "@posthog/ui/features/canvas/hooks/useDashboards";
+import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore";
 import { copyCanvasLink } from "@posthog/ui/features/canvas/utils/copyCanvasLink";
 import { useInView } from "@posthog/ui/primitives/hooks/useInView";
-import { toast } from "@posthog/ui/primitives/toast";
 import { track } from "@posthog/ui/shell/analytics";
 import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary";
 import { Box, Flex, Grid } from "@radix-ui/themes";
@@ -106,10 +114,20 @@ const DashboardCard = memo(function DashboardCard({
   summary: DashboardSummary;
   templateLabel: string;
 }) {
+  // While the canvas is inside its delete-undo window the card stays in the
+  // grid — dimmed, with a pulsing trash can over its preview — so undoing puts
+  // it back exactly where it was rather than re-inserting a row.
+  const deleting = useIsCanvasPendingDelete(summary.id);
+
   // The React source rides along in the list response, so the grid renders
   // previews without a per-card fetch (no N+1 of get()).
   return (
-    <Box className="group relative">
+    <Box
+      className={cn(
+        "group relative",
+        deleting && "pointer-events-none opacity-60",
+      )}
+    >
       <Link
         to="/website/$channelId/dashboards/$dashboardId"
         params={{ channelId, dashboardId: summary.id }}
@@ -125,7 +143,22 @@ const DashboardCard = memo(function DashboardCard({
         }
       >
         <Card className="gap-0 overflow-hidden p-0">
-          <FreeformPreview code={summary.code} />
+          <Box className="relative">
+            <FreeformPreview code={summary.code} />
+            {deleting && (
+              <Flex
+                align="center"
+                justify="center"
+                gap="2"
+                className="absolute inset-0 bg-gray-1/80"
+              >
+                <TrashIcon size={18} className="animate-pulse text-red-9" />
+                <Text size="xs" variant="muted">
+                  Deleting…
+                </Text>
+              </Flex>
+            )}
+          </Box>
           <CardContent className="flex flex-col gap-0.5 p-3">
             <Flex align="center" justify="between" gap="2">
               <Text size="sm" weight="medium" className="truncate">
@@ -223,31 +256,22 @@ function DashboardCardMenu({
   channelId: string;
 }) {
   const [open, setOpen] = useState(false);
-  const { deleteDashboard, isDeleting } = useDashboardMutations();
+  // "Delete…" opens a confirmation rather than deleting inline — the canvas and
+  // its version history go away for everyone in the channel.
+  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
+  const { invalidateDashboards } = useDashboardMutations();
 
-  const onDelete = () => {
-    deleteDashboard(id)
-      .then(() => {
-        track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
-          action_type: "delete",
-          surface: "dashboards_grid",
-          channel_id: channelId,
-          dashboard_id: id,
-          success: true,
-        });
-      })
-      .catch((error) => {
-        track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
-          action_type: "delete",
-          surface: "dashboards_grid",
-          channel_id: channelId,
-          dashboard_id: id,
-          success: false,
-        });
-        toast.error("Couldn't delete canvas", {
-          description: error instanceof Error ? error.message : String(error),
-        });
-      });
+  // The card disappears immediately, but the delete isn't sent until the undo
+  // toast's timer runs out — Undo simply cancels it.
+  const confirmDelete = () => {
+    setConfirmDeleteOpen(false);
+    deleteCanvasWithUndo({
+      dashboardId: id,
+      channelId,
+      name,
+      surface: "dashboards_grid",
+      invalidate: invalidateDashboards,
+    });
   };
 
   return (
@@ -280,14 +304,38 @@ function DashboardCardMenu({
           </DropdownMenuItem>
           <DropdownMenuItem
             variant="destructive"
-            disabled={isDeleting}
-            onClick={onDelete}
+            onClick={() => setConfirmDeleteOpen(true)}
           >
             <TrashIcon size={14} />
-            Delete
+            Delete…
           </DropdownMenuItem>
         </DropdownMenuContent>
       </DropdownMenu>
+      {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */}
+      <AlertDialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
+        <AlertDialogContent className="max-w-md">
+          <AlertDialogHeader>
+            <AlertDialogTitle>Delete canvas</AlertDialogTitle>
+            <AlertDialogDescription>
+              Permanently delete <span className="font-medium">{name}</span>?
+              This deletes its code and version history for everyone in the
+              channel and cannot be undone.
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogClose
+              render={
+                <Button variant="outline" size="sm">
+                  Cancel
+                </Button>
+              }
+            />
+            <Button variant="destructive" size="sm" onClick={confirmDelete}>
+              Delete
+            </Button>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
     </Box>
   );
 }
diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx
index 70105c1e05..67de99105d 100644
--- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx
+++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx
@@ -5,9 +5,17 @@ import {
   LinkIcon,
   PencilSimpleIcon,
   PushPinIcon,
+  TrashIcon,
   XIcon,
 } from "@phosphor-icons/react";
 import {
+  AlertDialog,
+  AlertDialogClose,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
   Button,
   DropdownMenu,
   DropdownMenuContent,
@@ -18,6 +26,7 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
 import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb";
 import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon";
 import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu";
+import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo";
 import { CanvasFrameHost } from "@posthog/ui/features/canvas/freeform/CanvasFrameHost";
 import { useCanvasFrameStore } from "@posthog/ui/features/canvas/freeform/canvasFrameStore";
 import { CANVAS_QUERY_KEY } from "@posthog/ui/features/canvas/freeform/freeformDataBridge";
@@ -50,7 +59,7 @@ import {
   useParams,
   useRouterState,
 } from "@tanstack/react-router";
-import type { ReactNode } from "react";
+import { type ReactNode, useState } from "react";
 
 function threadIdFor(dashboardId: string): string {
   return `dashboard:${dashboardId}`;
@@ -72,8 +81,30 @@ function FreeformEditControls({
   const editing = useIsDashboardEditing(dashboardId);
   const setEditing = useDashboardEditStore((s) => s.setEditing);
   const { dashboard } = useDashboard(dashboardId);
-  const { forkFreeform, isCreating, setPinned } = useDashboardMutations();
+  const { forkFreeform, isCreating, setPinned, invalidateDashboards } =
+    useDashboardMutations();
   const isPinned = dashboard?.pinnedAt != null;
+  // "Delete…" opens a confirmation rather than deleting inline — the canvas and
+  // its version history go away for everyone in the channel.
+  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
+
+  // Once confirmed the canvas vanishes from every list and we leave for the
+  // space's artifacts list, but the delete isn't sent until the undo toast's
+  // timer runs out — Undo simply cancels it.
+  const confirmDelete = () => {
+    setConfirmDeleteOpen(false);
+    deleteCanvasWithUndo({
+      dashboardId,
+      channelId,
+      name: dashboard?.name ?? "Canvas",
+      surface: "canvas",
+      invalidate: invalidateDashboards,
+    });
+    void navigate({
+      to: "/website/$channelId/artifacts",
+      params: { channelId },
+    });
+  };
 
   const onTogglePin = () => {
     void setPinned(dashboardId, !isPinned)
@@ -250,8 +281,41 @@ function FreeformEditControls({
             <PushPinIcon size={14} weight={isPinned ? "fill" : "regular"} />
             {isPinned ? "Unpin from channel" : "Pin to channel"}
           </DropdownMenuItem>
+          <DropdownMenuItem
+            variant="destructive"
+            onClick={() => setConfirmDeleteOpen(true)}
+          >
+            <TrashIcon size={14} />
+            Delete…
+          </DropdownMenuItem>
         </DropdownMenuContent>
       </DropdownMenu>
+      {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */}
+      <AlertDialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
+        <AlertDialogContent className="max-w-md">
+          <AlertDialogHeader>
+            <AlertDialogTitle>Delete canvas</AlertDialogTitle>
+            <AlertDialogDescription>
+              Permanently delete{" "}
+              <span className="font-medium">{dashboard?.name ?? "Canvas"}</span>
+              ? This deletes its code and version history for everyone in the
+              channel and cannot be undone.
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogClose
+              render={
+                <Button variant="outline" size="sm">
+                  Cancel
+                </Button>
+              }
+            />
+            <Button variant="destructive" size="sm" onClick={confirmDelete}>
+              Delete
+            </Button>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
       <Button
         variant="outline"
         size="sm"
diff --git a/packages/ui/src/features/canvas/deleteCanvasWithUndo.test.ts b/packages/ui/src/features/canvas/deleteCanvasWithUndo.test.ts
new file mode 100644
index 0000000000..2337f486e4
--- /dev/null
+++ b/packages/ui/src/features/canvas/deleteCanvasWithUndo.test.ts
@@ -0,0 +1,104 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const deleteMutate = vi.fn().mockResolvedValue(undefined);
+const toastSuccess = vi.fn();
+const toastError = vi.fn();
+
+vi.mock("@posthog/ui/features/canvas/hostClient", () => ({
+  hostClient: () => ({ dashboards: { delete: { mutate: deleteMutate } } }),
+}));
+vi.mock("@posthog/ui/primitives/toast", () => ({
+  toast: {
+    success: (...args: unknown[]) => toastSuccess(...args),
+    error: (...args: unknown[]) => toastError(...args),
+    dismiss: vi.fn(),
+  },
+}));
+vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() }));
+
+import {
+  CANVAS_DELETE_UNDO_MS,
+  deleteCanvasWithUndo,
+} from "@posthog/ui/features/canvas/deleteCanvasWithUndo";
+import { usePendingCanvasDeleteStore } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore";
+
+function schedule(invalidate = vi.fn()) {
+  deleteCanvasWithUndo({
+    dashboardId: "d1",
+    channelId: "c1",
+    name: "Weekly report",
+    surface: "dashboards_grid",
+    invalidate,
+  });
+  return invalidate;
+}
+
+// The Undo button handed to the toast.
+function undo(): () => void {
+  const options = toastSuccess.mock.calls.at(-1)?.[1] as {
+    action: { onClick: () => void };
+  };
+  return options.action.onClick;
+}
+
+function isPending(id: string): boolean {
+  return !!usePendingCanvasDeleteStore.getState().pending[id];
+}
+
+describe("deleteCanvasWithUndo", () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.clearAllMocks();
+    usePendingCanvasDeleteStore.setState({ pending: {} });
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it("hides the canvas immediately but sends nothing until the window closes", async () => {
+    const invalidate = schedule();
+
+    expect(isPending("d1")).toBe(true);
+    expect(deleteMutate).not.toHaveBeenCalled();
+
+    await vi.advanceTimersByTimeAsync(CANVAS_DELETE_UNDO_MS);
+
+    expect(deleteMutate).toHaveBeenCalledWith({ id: "d1" });
+    expect(invalidate).toHaveBeenCalled();
+    expect(isPending("d1")).toBe(false);
+  });
+
+  it("undo cancels the delete outright — nothing is sent", async () => {
+    schedule();
+    undo()();
+
+    expect(isPending("d1")).toBe(false);
+
+    await vi.advanceTimersByTimeAsync(CANVAS_DELETE_UNDO_MS * 2);
+
+    expect(deleteMutate).not.toHaveBeenCalled();
+  });
+
+  it("restores the canvas and toasts when the delete fails", async () => {
+    deleteMutate.mockRejectedValueOnce(new Error("host offline"));
+    schedule();
+
+    await vi.advanceTimersByTimeAsync(CANVAS_DELETE_UNDO_MS);
+
+    expect(isPending("d1")).toBe(false);
+    expect(toastError).toHaveBeenCalledWith(
+      "Couldn't delete canvas",
+      expect.objectContaining({ description: "host offline" }),
+    );
+  });
+
+  it("re-deleting the same canvas restarts the window instead of stacking commits", async () => {
+    schedule();
+    await vi.advanceTimersByTimeAsync(CANVAS_DELETE_UNDO_MS / 2);
+    schedule();
+    await vi.advanceTimersByTimeAsync(CANVAS_DELETE_UNDO_MS);
+
+    expect(deleteMutate).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/packages/ui/src/features/canvas/deleteCanvasWithUndo.ts b/packages/ui/src/features/canvas/deleteCanvasWithUndo.ts
new file mode 100644
index 0000000000..0b2f0c5d71
--- /dev/null
+++ b/packages/ui/src/features/canvas/deleteCanvasWithUndo.ts
@@ -0,0 +1,123 @@
+import {
+  ANALYTICS_EVENTS,
+  type ChannelsSurface,
+} from "@posthog/shared/analytics-events";
+import { hostClient } from "@posthog/ui/features/canvas/hostClient";
+import { usePendingCanvasDeleteStore } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore";
+import { toast } from "@posthog/ui/primitives/toast";
+import { track } from "@posthog/ui/shell/analytics";
+
+// How long the "Deleted artifact" toast stays up — and, because nothing is sent
+// to the host until it expires, how long the user has to undo.
+export const CANVAS_DELETE_UNDO_MS = 8000;
+
+// Canvases waiting out their undo window, so a second delete of the same canvas
+// (or an undo) can cancel the pending commit. Module-level on purpose: the
+// window outlives the component that started it (deleting from the canvas
+// itself navigates away immediately).
+const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
+
+interface DeleteCanvasWithUndoOptions {
+  dashboardId: string;
+  channelId: string;
+  /** Canvas name, for the toast copy. */
+  name: string;
+  surface: ChannelsSurface;
+  /** Refresh the canvas queries once the delete actually lands. */
+  invalidate: () => void;
+}
+
+/**
+ * Delete a canvas with an undo window: the canvas disappears from every list
+ * immediately (via the pending store) but the host isn't told until the toast's
+ * timer runs out. Undo just cancels that timer, so nothing is ever recreated.
+ */
+export function deleteCanvasWithUndo({
+  dashboardId,
+  channelId,
+  name,
+  surface,
+  invalidate,
+}: DeleteCanvasWithUndoOptions): void {
+  const { markPending, clearPending } = usePendingCanvasDeleteStore.getState();
+  const toastId = `canvas-delete-undo-${dashboardId}`;
+
+  // A repeat delete for the same canvas restarts the window rather than
+  // stacking two commits.
+  const existing = pendingTimers.get(dashboardId);
+  if (existing) clearTimeout(existing);
+
+  markPending(dashboardId);
+
+  const commit = async () => {
+    pendingTimers.delete(dashboardId);
+    try {
+      await hostClient().dashboards.delete.mutate({ id: dashboardId });
+      track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
+        action_type: "delete",
+        surface,
+        channel_id: channelId,
+        dashboard_id: dashboardId,
+        success: true,
+      });
+      invalidate();
+    } catch (error) {
+      track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
+        action_type: "delete",
+        surface,
+        channel_id: channelId,
+        dashboard_id: dashboardId,
+        success: false,
+      });
+      toast.error("Couldn't delete canvas", {
+        description: error instanceof Error ? error.message : String(error),
+      });
+    } finally {
+      // Either it's gone from the server (and the refreshed list won't include
+      // it) or the delete failed and it should come back.
+      clearPending(dashboardId);
+    }
+  };
+
+  pendingTimers.set(
+    dashboardId,
+    setTimeout(() => void commit(), CANVAS_DELETE_UNDO_MS),
+  );
+
+  toast.success("Deleted artifact", {
+    id: toastId,
+    description: name,
+    duration: CANVAS_DELETE_UNDO_MS,
+    action: {
+      label: "Undo",
+      onClick: () => {
+        toast.dismiss(toastId);
+        undoCanvasDelete({ dashboardId, channelId, surface });
+      },
+    },
+  });
+}
+
+function undoCanvasDelete({
+  dashboardId,
+  channelId,
+  surface,
+}: {
+  dashboardId: string;
+  channelId: string;
+  surface: ChannelsSurface;
+}): void {
+  const timer = pendingTimers.get(dashboardId);
+  // Nothing to cancel means the window already closed and the delete is in
+  // flight or done; leave the pending flag to `commit`.
+  if (!timer) return;
+  clearTimeout(timer);
+  pendingTimers.delete(dashboardId);
+  usePendingCanvasDeleteStore.getState().clearPending(dashboardId);
+  track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
+    action_type: "delete_undo",
+    surface,
+    channel_id: channelId,
+    dashboard_id: dashboardId,
+  });
+}
diff --git a/packages/ui/src/features/canvas/hooks/useDashboards.ts b/packages/ui/src/features/canvas/hooks/useDashboards.ts
index af8267c501..7af74c90db 100644
--- a/packages/ui/src/features/canvas/hooks/useDashboards.ts
+++ b/packages/ui/src/features/canvas/hooks/useDashboards.ts
@@ -36,6 +36,9 @@ export function useDashboards(channelId: string | undefined): {
       { enabled: !!channelId, staleTime: 5_000 },
     ),
   );
+  // Canvases inside their delete-undo window stay in the list — surfaces mark
+  // them as deleting (see usePendingCanvasDeleteStore) rather than removing a
+  // row that Undo would put straight back.
   return { dashboards: data ?? [], isLoading };
 }
 
@@ -115,6 +118,9 @@ export function useDashboardMutations() {
   );
 
   return {
+    // Refresh the canvas queries after a mutation that didn't go through this
+    // hook (the undo-window delete commits outside React).
+    invalidateDashboards: invalidate,
     createDashboard: (channelId: string, name: string, templateId?: string) =>
       create.mutateAsync({ channelId, name, templateId }),
     deleteDashboard: (id: string) => remove.mutateAsync({ id }),
diff --git a/packages/ui/src/features/canvas/stores/pendingCanvasDeleteStore.ts b/packages/ui/src/features/canvas/stores/pendingCanvasDeleteStore.ts
new file mode 100644
index 0000000000..de774fcca3
--- /dev/null
+++ b/packages/ui/src/features/canvas/stores/pendingCanvasDeleteStore.ts
@@ -0,0 +1,29 @@
+import { create } from "zustand";
+
+interface PendingCanvasDeleteState {
+  // Canvases the user has deleted but whose delete hasn't been sent yet — the
+  // undo window. They stay in their lists, marked as deleting, and the mark
+  // simply clears (nothing is recreated) if the user hits Undo. State only: the
+  // timer and the eventual commit live in `deleteCanvasWithUndo`.
+  pending: Record<string, boolean>;
+  markPending: (dashboardId: string) => void;
+  clearPending: (dashboardId: string) => void;
+}
+
+export const usePendingCanvasDeleteStore = create<PendingCanvasDeleteState>(
+  (set) => ({
+    pending: {},
+    markPending: (dashboardId) =>
+      set((s) => ({ pending: { ...s.pending, [dashboardId]: true } })),
+    clearPending: (dashboardId) =>
+      set((s) => {
+        const { [dashboardId]: _dropped, ...rest } = s.pending;
+        return { pending: rest };
+      }),
+  }),
+);
+
+/** True while this canvas is inside its delete-undo window. */
+export function useIsCanvasPendingDelete(dashboardId: string): boolean {
+  return usePendingCanvasDeleteStore((s) => !!s.pending[dashboardId]);
+}
