diff --git a/canvas/e2e/context-menu-delete.spec.ts b/canvas/e2e/context-menu-delete.spec.ts new file mode 100644 index 00000000..4b437323 --- /dev/null +++ b/canvas/e2e/context-menu-delete.spec.ts @@ -0,0 +1,131 @@ +import { test, expect } from "@playwright/test"; + +/** + * Playwright E2E for context-menu → delete confirm flow. + * Regression test for the portal/race bug fixed in PR #1133: + * clicking "Delete" in the context menu did nothing because the + * portal-rendered ConfirmDialog was closed by the menu's outside-click + * handler before onConfirm could fire. + * + * The fix hoists dialog state to the canvas store via `setPendingDelete`, + * which survives ContextMenu unmount. This test exercises the full + * interaction in a real browser environment. + * + * Requires: platform on :8080, canvas on :3000. + */ +const API = process.env.E2E_API_URL ?? "http://localhost:8080"; + +test.describe("Context Menu → Delete Confirm", () => { + test.beforeEach(async ({ request }) => { + // Ensure at least one workspace exists so the menu can be triggered + const res = await request.get(`${API}/workspaces`); + const workspaces = (await res.json()) as Array<{ id: string; name: string }>; + if (workspaces.length === 0) { + test.skip("No workspaces on canvas — cannot test context menu"); + } + }); + + test("Delete button opens ConfirmDialog and clicking Confirm deletes the workspace", async ({ + page, + request, + }) => { + // 1. Create a workspace to delete (leaf node — no children, no cascade) + const create = await request.post(`${API}/workspaces`, { + data: { name: "E2E Delete Test", tier: 1, runtime: "claude-code" }, + headers: { "Content-Type": "application/json" }, + }); + const workspace = (await create.json()) as { id: string; name: string }; + const wsId = workspace.id; + + // Register so the node appears online on the canvas + await request.post(`${API}/registry/register`, { + data: { + id: wsId, + url: `http://localhost:9999`, + agent_card: { name: "E2E Delete Test", skills: [] }, + }, + headers: { "Content-Type": "application/json" }, + }); + + // 2. Open the canvas and wait for the workspace node + await page.goto("/", { waitUntil: "networkidle" }); + await page.waitForTimeout(2000); // allow WS to appear + + // Find the workspace node on the canvas + const node = page.locator(`.react-flow__node`).filter({ hasText: "E2E Delete Test" }).first(); + await expect(node).toBeVisible({ timeout: 10000 }); + + // 3. Right-click to open context menu + await node.click({ button: "right" }); + const menu = page.locator('[role="menu"]').first(); + await expect(menu).toBeVisible({ timeout: 3000 }); + await expect(menu).toHaveAttribute("aria-label", /E2E Delete Test/i); + + // 4. Click "Delete" — should open the ConfirmDialog (not close silently) + const deleteBtn = menu.getByRole("menuitem").filter({ hasText: /Delete/i }); + await expect(deleteBtn).toBeVisible(); + await deleteBtn.click(); + + // 5. ConfirmDialog should appear (portal renders into document.body) + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible({ timeout: 3000 }); + await expect(dialog).toContainText(/delete/i); + await expect(dialog.getByRole("button", { name: /confirm|delete/i })).toBeVisible(); + + // 6. Click Confirm — workspace should be deleted + await dialog.getByRole("button", { name: /confirm|delete/i }).first().click(); + + // 7. Dialog should close + await expect(dialog).not.toBeVisible({ timeout: 3000 }); + + // 8. Node should disappear from canvas + await expect( + page.locator(`.react-flow__node`).filter({ hasText: "E2E Delete Test" }) + ).not.toBeVisible({ timeout: 5000 }); + + // 9. API confirms workspace is gone + const getRes = await request.get(`${API}/workspaces/${wsId}`); + expect(getRes.status()).toBeGreaterThanOrEqual(400); // 404 or similar + }); + + test("Cancel closes the dialog and the workspace remains", async ({ page, request }) => { + const res = await request.get(`${API}/workspaces`); + const workspaces = (await res.json()) as Array<{ id: string; name: string }>; + if (workspaces.length === 0) { + test.skip("No workspaces"); + } + + const ws = workspaces[0]; + + // Register if not already + await request.post(`${API}/registry/register`, { + data: { id: ws.id, url: `http://localhost:9999`, agent_card: { name: ws.name, skills: [] } }, + headers: { "Content-Type": "application/json" }, + }); + + await page.goto("/", { waitUntil: "networkidle" }); + await page.waitForTimeout(2000); + + const node = page.locator(`.react-flow__node`).filter({ hasText: ws.name }).first(); + await node.click({ button: "right" }); + + const menu = page.locator('[role="menu"]').first(); + await expect(menu).toBeVisible(); + + // Get workspace name before we click Delete (can't easily look it up after) + const wsName = ws.name; + + await menu.getByRole("menuitem").filter({ hasText: /Delete/i }).click(); + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible({ timeout: 3000 }); + + // Cancel + await dialog.getByRole("button", { name: /cancel/i }).first().click(); + await expect(dialog).not.toBeVisible({ timeout: 3000 }); + + // Node still on canvas + await expect( + page.locator(`.react-flow__node`).filter({ hasText: wsName }).first() + ).toBeVisible({ timeout: 5000 }); + }); +}); \ No newline at end of file diff --git a/canvas/package-lock.json b/canvas/package-lock.json index f4defc1f..c7f76c9e 100644 --- a/canvas/package-lock.json +++ b/canvas/package-lock.json @@ -80,6 +80,7 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -95,6 +96,7 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -197,7 +199,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -221,11 +222,31 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -980,7 +1001,6 @@ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.59.1" }, @@ -1829,6 +1849,27 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", @@ -1990,7 +2031,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -2113,7 +2155,6 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2123,7 +2164,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2134,7 +2174,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2372,6 +2411,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2382,6 +2422,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2557,7 +2598,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2874,7 +2914,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3039,7 +3078,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3651,7 +3691,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3661,7 +3700,8 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jsdom": { "version": "25.0.1", @@ -3669,7 +3709,6 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -4007,6 +4046,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5234,7 +5274,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5391,6 +5430,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5445,7 +5485,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5455,7 +5494,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5468,7 +5506,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -6017,7 +6056,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -6139,7 +6177,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6478,7 +6515,6 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index 3b54cc17..3d869a81 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -23,7 +23,10 @@ export function ContextMenu() { const setPanelTab = useCanvasStore((s) => s.setPanelTab); const nestNode = useCanvasStore((s) => s.nestNode); const contextNodeId = contextMenu?.nodeId ?? null; - const hasChildren = useCanvasStore((s) => contextNodeId ? s.nodes.some((n) => n.data.parentId === contextNodeId) : false); + const children = useCanvasStore((s) => + contextNodeId ? s.nodes.filter((n) => n.data.parentId === contextNodeId) : [] + ); + const hasChildren = children.length > 0; const setPendingDelete = useCanvasStore((s) => s.setPendingDelete); const ref = useRef(null); const [actionLoading, setActionLoading] = useState(false); @@ -164,7 +167,7 @@ export function ContextMenu() { // it survives ContextMenu unmount. Closing the menu here avoids the // prior race where the portal dialog's Confirm click was treated as // "outside" by the menu's outside-click handler. - setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren }); + setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: children.map(c => ({ id: c.id, name: c.data.name })) }); closeContextMenu(); }, [contextMenu, setPendingDelete, closeContextMenu]); diff --git a/canvas/src/components/DeleteCascadeConfirmDialog.tsx b/canvas/src/components/DeleteCascadeConfirmDialog.tsx new file mode 100644 index 00000000..e31114b7 --- /dev/null +++ b/canvas/src/components/DeleteCascadeConfirmDialog.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface Child { + id: string; + name: string; +} + +interface Props { + name: string; + children: Child[]; + checked: boolean; + onCheckedChange: (v: boolean) => void; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * Cascade-delete confirmation dialog. + * + * When a workspace has children, the operator must explicitly tick + * "I understand this will cascade" before Delete All activates. This + * prevents accidental mass-deletion when ?confirm=true is always sent. + * + * Per WCAG 2.1 SC 2.4.3: focus moves to dialog on open. + * Per WCAG 2.1 SC 3.3.2: labels associated with inputs. + */ +export function DeleteCascadeConfirmDialog({ + name, + children, + checked, + onCheckedChange, + onConfirm, + onCancel, +}: Props) { + const dialogRef = useRef(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + // Focus first interactive element when dialog opens (WCAG 2.4.3) + useEffect(() => { + if (!mounted) return; + const raf = requestAnimationFrame(() => { + dialogRef.current?.querySelector("button")?.focus(); + }); + return () => cancelAnimationFrame(raf); + }, [mounted]); + + // Keyboard: Escape cancels, Enter confirms (only when enabled), Tab trapped + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { onCancel(); return; } + if (e.key === "Enter" && checked) { onConfirm(); return; } + if (e.key === "Tab" && dialogRef.current) { + const focusable = Array.from( + dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => !el.hasAttribute("disabled")); + if (focusable.length === 0) { e.preventDefault(); return; } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { e.preventDefault(); last.focus(); } + } else { + if (document.activeElement === last) { e.preventDefault(); first.focus(); } + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onCancel, onConfirm, checked]); + + if (!mounted) return null; + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+
+

+ Delete Workspace and Children +

+
+ +
+ {/* Warning */} +
+
+ + + + +
+

+ "{name}" has{" "} + {children.length} child{" "} + {children.length === 1 ? "workspace" : "workspaces"}: +

+
+ + {/* Child list */} +
    + {children.map((c) => ( +
  • {c.name}
  • + ))} +
+ + {/* Cascade warning */} +
+

+ Deleting will cascade — all child workspaces and their data will be permanently removed. This cannot be undone. +

+
+ + {/* Checkbox guard */} + +
+ +
+ + +
+
+
, + document.body + ); +} \ No newline at end of file diff --git a/canvas/src/components/StatusDot.tsx b/canvas/src/components/StatusDot.tsx index 9dcddb41..89d93288 100644 --- a/canvas/src/components/StatusDot.tsx +++ b/canvas/src/components/StatusDot.tsx @@ -14,6 +14,8 @@ export function StatusDot({ return (