molecule-core/canvas/src/components/ContextMenu.tsx
molecule-ai[bot] c0d5e528a4 fix(canvas): cascade-delete UX — require checkbox before Delete All (#1314)
* fix(canvas/test): restore test regressions from PR #1243

PR #1243 introduced two regressions in the canvas vitest suite:

1. ContextMenu.keyboard.test.tsx: the setPendingDelete call now
   passes `{hasChildren, id, name}` (not just `{id, name}`). Updated
   the keyboard-a11y test assertion to match the new store shape.

2. orgs-page.test.tsx: mockFetch.mockResolvedValueOnce() returned a
   plain object that didn't match the two-argument (url, options)
   call signature used by the component's fetch wrapper. Switched to
   mockImplementationOnce returning a rejected Promise — matching
   real fetch's rejection contract — and added runAllTimersAsync after
   advanceTimersByTimeAsync(50) to flush React state updates.

54 test files · 813 tests · all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(canvas): replace bounding-box intersection with distance threshold for nest detection

ReactFlow's getIntersectingNodes uses bounding-box overlap detection, which
fires the drag-over state whenever any part of two nodes' position rectangles
overlap — even when the dragged node is far from the target. This made the
"Nest Workspace" dialog appear from large distances.

Fix: scan all nodes on each drag tick and set dragOverNodeId to the closest
node within NEST_PROXIMITY_THRESHOLD (150 px, center-to-center). This matches
the intuitive behavior: nest only when the node is actually dropped near another.

Constants:
- NEST_PROXIMITY_THRESHOLD = 150px (~60% of a collapsed node's width)
- DEFAULT_NODE_WIDTH = 245px (mid-range of min/max node widths)
- DEFAULT_NODE_HEIGHT = 110px

Also removed the unused getIntersectingNodes import (was causing duplicate
identifier error when both onNodeDrag and the zoom handler called useReactFlow
in the same component scope).

Closes #1052.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(canvas): cascade-delete UX — show child count and require checkbox before Delete All

Issue #1137: with ?confirm=true always sent, a single confirmation silently
cascades — a team lead with 20 children gets nuked on one click.

Changes:
- store/canvas.ts: pendingDelete type now includes children: {id, name}[]
- ContextMenu.tsx: passes child list to setPendingDelete on Delete click
- DeleteCascadeConfirmDialog.tsx: new component — shows child names, a
  cascade warning, and requires the operator to tick a checkbox before
  Delete All activates. Disabled by default; only enables after checkbox.
- Canvas.tsx: conditionally renders DeleteCascadeConfirmDialog for
  hasChildren workspaces, or plain ConfirmDialog for leaf workspaces.
  confirmDelete requires cascadeConfirmChecked=true when hasChildren.
- ContextMenu.keyboard.test.tsx: updated setPendingDelete assertion to
  include children:[] (no children in the test fixture).

813 tests pass.

Closes #1137.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Molecule AI Core-UIUX <core-uiux@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 07:06:45 +00:00

311 lines
11 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { api } from "@/lib/api";
import { showToast } from "./Toaster";
import { statusDotClass } from "@/lib/design-tokens";
interface MenuItem {
label: string;
icon: string;
action: () => void;
danger?: boolean;
disabled?: boolean;
divider?: boolean;
}
export function ContextMenu() {
const contextMenu = useCanvasStore((s) => s.contextMenu);
const closeContextMenu = useCanvasStore((s) => s.closeContextMenu);
const updateNodeData = useCanvasStore((s) => s.updateNodeData);
const selectNode = useCanvasStore((s) => s.selectNode);
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
const nestNode = useCanvasStore((s) => s.nestNode);
const contextNodeId = contextMenu?.nodeId ?? null;
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<HTMLDivElement>(null);
const [actionLoading, setActionLoading] = useState(false);
// Auto-focus first enabled item when menu opens
useEffect(() => {
if (!contextMenu) return;
requestAnimationFrame(() => {
const first = ref.current?.querySelector<HTMLButtonElement>("button:not(:disabled)");
first?.focus();
});
}, [contextMenu?.nodeId]);
// Close on click outside or Escape
useEffect(() => {
if (!contextMenu) return;
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as HTMLElement)) {
closeContextMenu();
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeContextMenu();
};
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleKey);
};
}, [contextMenu, closeContextMenu]);
// Arrow-key navigation within the menu
const handleMenuKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
closeContextMenu();
return;
}
if (e.key === "Tab") {
e.preventDefault();
closeContextMenu();
return;
}
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const buttons = Array.from(
ref.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? []
);
const active = document.activeElement as HTMLButtonElement;
const idx = buttons.indexOf(active);
const next =
e.key === "ArrowDown"
? idx === -1
? 0
: (idx + 1) % buttons.length
: idx <= 0
? buttons.length - 1
: idx - 1;
buttons[next]?.focus();
},
[closeContextMenu]
);
const handleExportBundle = useCallback(async () => {
if (!contextMenu || actionLoading) return;
setActionLoading(true);
try {
const bundle = await api.get<Record<string, unknown>>(`/bundles/export/${contextMenu.nodeId}`);
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${(contextMenu.nodeData.name || "workspace").toLowerCase().replace(/\s+/g, "-")}.bundle.json`;
a.click();
URL.revokeObjectURL(url);
showToast("Bundle exported", "success");
} catch (e) {
showToast("Export failed", "error");
} finally {
setActionLoading(false);
}
closeContextMenu();
}, [contextMenu, closeContextMenu, actionLoading]);
const handleDuplicate = useCallback(async () => {
if (!contextMenu || actionLoading) return;
setActionLoading(true);
try {
const bundle = await api.get<Record<string, unknown>>(`/bundles/export/${contextMenu.nodeId}`);
await api.post("/bundles/import", bundle);
} catch (e) {
showToast("Duplicate failed", "error");
} finally {
setActionLoading(false);
}
closeContextMenu();
}, [contextMenu, closeContextMenu, actionLoading]);
const handleRestart = useCallback(async () => {
if (!contextMenu) return;
try {
await api.post(`/workspaces/${contextMenu.nodeId}/restart`, {});
updateNodeData(contextMenu.nodeId, { status: "provisioning" });
} catch (e) {
showToast("Restart failed", "error");
}
closeContextMenu();
}, [contextMenu, updateNodeData, closeContextMenu]);
const handlePause = useCallback(async () => {
if (!contextMenu) return;
const nodeId = contextMenu.nodeId;
closeContextMenu();
try {
await api.post(`/workspaces/${nodeId}/pause`, {});
updateNodeData(nodeId, { status: "paused" });
} catch (e) {
showToast("Pause failed", "error");
}
}, [contextMenu, updateNodeData, closeContextMenu]);
const handleResume = useCallback(async () => {
if (!contextMenu) return;
const nodeId = contextMenu.nodeId;
closeContextMenu();
try {
await api.post(`/workspaces/${nodeId}/resume`, {});
updateNodeData(nodeId, { status: "provisioning" });
} catch (e) {
showToast("Resume failed", "error");
}
}, [contextMenu, updateNodeData, closeContextMenu]);
const handleDelete = useCallback(() => {
if (!contextMenu) return;
// Hoist delete confirmation to the Canvas-level dialog (via store) so
// 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, children: children.map(c => ({ id: c.id, name: c.data.name })) });
closeContextMenu();
}, [contextMenu, setPendingDelete, closeContextMenu]);
const handleViewDetails = useCallback(() => {
if (!contextMenu) return;
selectNode(contextMenu.nodeId);
setPanelTab("details");
closeContextMenu();
}, [contextMenu, selectNode, setPanelTab, closeContextMenu]);
const handleOpenChat = useCallback(() => {
if (!contextMenu) return;
selectNode(contextMenu.nodeId);
setPanelTab("chat");
closeContextMenu();
}, [contextMenu, selectNode, setPanelTab, closeContextMenu]);
const handleOpenTerminal = useCallback(() => {
if (!contextMenu) return;
selectNode(contextMenu.nodeId);
setPanelTab("terminal");
closeContextMenu();
}, [contextMenu, selectNode, setPanelTab, closeContextMenu]);
const handleExpand = useCallback(async () => {
if (!contextMenu) return;
try {
await api.post(`/workspaces/${contextMenu.nodeId}/expand`, {});
} catch (e) {
showToast("Expand failed", "error");
}
closeContextMenu();
}, [contextMenu, closeContextMenu]);
const handleCollapse = useCallback(async () => {
if (!contextMenu) return;
try {
await api.post(`/workspaces/${contextMenu.nodeId}/collapse`, {});
} catch (e) {
showToast("Collapse failed", "error");
}
closeContextMenu();
}, [contextMenu, closeContextMenu]);
const handleRemoveFromTeam = useCallback(async () => {
if (!contextMenu) return;
try {
await nestNode(contextMenu.nodeId, null);
showToast("Extracted from team", "success");
} catch {
showToast("Extract failed", "error");
}
closeContextMenu();
}, [contextMenu, nestNode, closeContextMenu]);
const handleZoomToTeam = useCallback(() => {
if (!contextMenu) return;
window.dispatchEvent(
new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: contextMenu.nodeId } })
);
closeContextMenu();
}, [contextMenu, closeContextMenu]);
if (!contextMenu) return null;
const isOfflineOrFailed = contextMenu.nodeData.status === "offline" || contextMenu.nodeData.status === "failed";
const isOnline = contextMenu.nodeData.status === "online";
const isPaused = contextMenu.nodeData.status === "paused";
const isChild = !!contextMenu.nodeData.parentId;
const items: MenuItem[] = [
{ label: "Details", icon: "i", action: handleViewDetails },
{ label: "Chat", icon: "💬", action: handleOpenChat, disabled: !isOnline },
{ label: "Terminal", icon: ">_", action: handleOpenTerminal, disabled: !isOnline },
{ label: "", icon: "", action: () => {}, divider: true },
{ label: "Export Bundle", icon: "📦", action: handleExportBundle },
{ label: "Duplicate", icon: "⧉", action: handleDuplicate },
...(isChild
? [{ label: "Extract from Team", icon: "⤴", action: handleRemoveFromTeam }]
: []),
...(hasChildren
? [
{ label: "Collapse Team", icon: "◁", action: handleCollapse },
{ label: "Zoom to Team", icon: "⊕", action: handleZoomToTeam },
]
: [{ label: "Expand to Team", icon: "▷", action: handleExpand }]),
{ label: "", icon: "", action: () => {}, divider: true },
...(isPaused
? [{ label: "Resume", icon: "▶", action: handleResume }]
: [{ label: "Pause", icon: "⏸", action: handlePause, disabled: !isOnline }]),
{ label: "Restart", icon: "↻", action: handleRestart, disabled: !(isOfflineOrFailed || isPaused) },
{ label: "Delete", icon: "✕", action: handleDelete, danger: true },
];
return (
<div
ref={ref}
role="menu"
aria-label={`Actions for ${contextMenu.nodeData.name}`}
onKeyDown={handleMenuKeyDown}
className="fixed z-[60] min-w-[200px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-xl shadow-2xl shadow-black/60 py-1 overflow-hidden"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{/* Header */}
<div className="px-3.5 py-2 border-b border-zinc-800/40 mb-0.5">
<div className="text-[11px] font-semibold text-zinc-200 truncate">{contextMenu.nodeData.name}</div>
<div className="flex items-center gap-1.5 mt-0.5">
<div
aria-hidden="true"
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`}
/>
<span className="text-[10px] text-zinc-500">{contextMenu.nodeData.status}</span>
</div>
</div>
{items.map((item, i) => {
if (item.divider) {
return <div key={i} role="separator" className="h-px bg-zinc-800/60 my-1" />;
}
return (
<button
key={i}
role="menuitem"
onClick={item.action}
disabled={item.disabled}
aria-disabled={item.disabled}
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-zinc-600 disabled:opacity-25 disabled:cursor-not-allowed ${
item.danger
? "text-red-400 hover:bg-red-950/40 hover:text-red-300"
: "text-zinc-300 hover:bg-zinc-800/40 hover:text-zinc-100"
}`}
>
<span aria-hidden="true" className="w-4 text-center text-[10px] shrink-0 opacity-50">{item.icon}</span>
{item.label}
</button>
);
})}
</div>
);
}