diff --git a/apps/web/components/kanban-card-menu-items.test.tsx b/apps/web/components/kanban-card-menu-items.test.tsx
new file mode 100644
index 0000000000..75d088b2a4
--- /dev/null
+++ b/apps/web/components/kanban-card-menu-items.test.tsx
@@ -0,0 +1,83 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
+import { KanbanCardDropdownMenuItems, type KanbanCardMenuEntry } from "./kanban-card-menu-items";
+
+// Regression: React synthetic events bubble through the fiber tree from a Radix portal; without stopPropagation the parent Card's onClick fires instead of the confirm dialog.
+describe("KanbanCardDropdownMenuItems — click propagation", () => {
+ function renderWithParent(entries: KanbanCardMenuEntry[], parentOnClick: () => void) {
+ return render(
+
+
+ open
+
+
+
+
+
,
+ );
+ }
+
+ it("clicking a menu item does not call the parent card's onClick", () => {
+ const onDelete = vi.fn();
+ const parentOnClick = vi.fn();
+ const entries: KanbanCardMenuEntry[] = [
+ {
+ kind: "item",
+ key: "delete",
+ label: "Delete",
+ onSelect: onDelete,
+ },
+ ];
+
+ renderWithParent(entries, parentOnClick);
+
+ const deleteItem = screen.getByRole("menuitem", { name: /delete/i });
+ fireEvent.click(deleteItem);
+
+ expect(onDelete).toHaveBeenCalledTimes(1);
+ expect(parentOnClick).not.toHaveBeenCalled();
+ });
+
+ it("clicking an archive menu item does not call the parent card's onClick", () => {
+ const onArchive = vi.fn();
+ const parentOnClick = vi.fn();
+ const entries: KanbanCardMenuEntry[] = [
+ {
+ kind: "item",
+ key: "archive",
+ label: "Archive",
+ onSelect: onArchive,
+ },
+ ];
+
+ renderWithParent(entries, parentOnClick);
+
+ fireEvent.click(screen.getByRole("menuitem", { name: /archive/i }));
+
+ expect(onArchive).toHaveBeenCalledTimes(1);
+ expect(parentOnClick).not.toHaveBeenCalled();
+ });
+
+ it("pointer-down on a menu item does not reach the parent (dnd-kit guard)", () => {
+ const parentOnPointerDown = vi.fn();
+ const entries: KanbanCardMenuEntry[] = [
+ { kind: "item", key: "delete", label: "Delete", onSelect: vi.fn() },
+ ];
+
+ render(
+
+
+ open
+
+
+
+
+
,
+ );
+
+ fireEvent.pointerDown(screen.getByRole("menuitem", { name: /delete/i }));
+
+ expect(parentOnPointerDown).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx
index c3da448b79..ea535a1ad0 100644
--- a/apps/web/components/kanban-card-menu-items.tsx
+++ b/apps/web/components/kanban-card-menu-items.tsx
@@ -404,8 +404,9 @@ function DropdownEntry({ entry }: { entry: KanbanCardMenuEntry }) {
data-testid={entry.testId}
disabled={entry.disabled}
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
- // React events bubble through the React tree even from a portal — stop here so the card's onClick doesn't navigate.
+ // React events bubble through the React tree even from a portal - stop here so click/pointer don't reach the parent Card's onClick or dnd-kit listeners.
onClick={(event) => event.stopPropagation()}
+ onPointerDown={(event) => event.stopPropagation()}
onSelect={(event) => {
event.stopPropagation();
if (!entry.disabled) entry.onSelect?.();
diff --git a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts
new file mode 100644
index 0000000000..9cec0783ba
--- /dev/null
+++ b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts
@@ -0,0 +1,83 @@
+import { test, expect } from "../../fixtures/test-base";
+import { KanbanPage } from "../../pages/kanban-page";
+
+// Regression: clicking Delete / Archive must show the confirm dialog, not navigate to the task.
+test.describe("Kanban card actions menu — delete/archive does not navigate", () => {
+ test("clicking Delete in card dropdown shows confirm dialog and does not navigate", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const task = await apiClient.createTask(seedData.workspaceId, "Card Menu Delete Task", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ });
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ const startUrl = testPage.url();
+
+ await kanban.openTaskActionsMenu(task.id);
+
+ const deleteItem = testPage.getByRole("menuitem", { name: "Delete" });
+ await expect(deleteItem).toBeVisible();
+ await deleteItem.click();
+
+ // Confirm dialog must appear with the task title
+ await expect(testPage.getByRole("alertdialog")).toBeVisible();
+ await expect(testPage.getByRole("alertdialog")).toContainText("Card Menu Delete Task");
+
+ // URL must not have changed (no navigation to /tasks/:id and no ?taskId=)
+ expect(testPage.url()).toBe(startUrl);
+ });
+
+ test("clicking Archive in card dropdown shows confirm dialog and does not navigate", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const task = await apiClient.createTask(seedData.workspaceId, "Card Menu Archive Task", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ });
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ const startUrl = testPage.url();
+
+ await kanban.openTaskActionsMenu(task.id);
+
+ const archiveItem = testPage.getByRole("menuitem", { name: "Archive" });
+ await expect(archiveItem).toBeVisible();
+ await archiveItem.click();
+
+ await expect(testPage.getByRole("alertdialog")).toBeVisible();
+ await expect(testPage.getByRole("alertdialog")).toContainText("Card Menu Archive Task");
+
+ expect(testPage.url()).toBe(startUrl);
+ });
+
+ test("clicking Delete with preview-on-click enabled does not open preview", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ await apiClient.saveUserSettings({ enable_preview_on_click: true });
+ const task = await apiClient.createTask(seedData.workspaceId, "Card Menu Preview Task", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ });
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ const startUrl = testPage.url();
+
+ await kanban.openTaskActionsMenu(task.id);
+ await testPage.getByRole("menuitem", { name: "Delete" }).click();
+
+ await expect(testPage.getByRole("alertdialog")).toBeVisible();
+ // Preview-on-click must not have fired — URL should still be the start URL
+ expect(testPage.url()).toBe(startUrl);
+ await expect(testPage.getByTestId("task-preview-panel")).not.toBeVisible();
+ });
+});