diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 0cb3c3de..16c299cb 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -1,21 +1,18 @@ "use client"; -import { useCallback, useRef, useMemo, useEffect, useState } from "react"; +import { useCallback, useMemo } from "react"; import { ReactFlow, ReactFlowProvider, Background, Controls, MiniMap, - useReactFlow, - type OnNodeDrag, - type Node, type Edge, BackgroundVariant, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; +import { useCanvasStore } from "@/store/canvas"; import { A2ATopologyOverlay } from "./A2ATopologyOverlay"; import { WorkspaceNode } from "./WorkspaceNode"; import { SidePanel } from "./SidePanel"; @@ -27,17 +24,19 @@ import { BundleDropZone } from "./BundleDropZone"; import { EmptyState } from "./EmptyState"; import { OnboardingWizard } from "./OnboardingWizard"; import { SearchDialog } from "./SearchDialog"; -import { Toaster } from "./Toaster"; +import { Toaster, showToast } from "./Toaster"; import { Toolbar } from "./Toolbar"; import { ConfirmDialog } from "./ConfirmDialog"; import { api } from "@/lib/api"; -import { showToast } from "./Toaster"; -// Phase 20 components import { SettingsPanel, DeleteConfirmDialog } from "./settings"; -// Phase 20.3 batch operations import { BatchActionBar } from "./BatchActionBar"; import { ProvisioningTimeout } from "./ProvisioningTimeout"; +import { DropTargetBadge } from "./canvas/DropTargetBadge"; +import { useDragHandlers } from "./canvas/useDragHandlers"; +import { useKeyboardShortcuts } from "./canvas/useKeyboardShortcuts"; +import { useCanvasViewport } from "./canvas/useCanvasViewport"; + const nodeTypes = { workspaceNode: WorkspaceNode, }; @@ -63,57 +62,33 @@ function CanvasInner() { const edges = useCanvasStore((s) => s.edges); const a2aEdges = useCanvasStore((s) => s.a2aEdges); const showA2AEdges = useCanvasStore((s) => s.showA2AEdges); - // Merge topology edges with A2A overlay edges via useMemo (no new object in selector) const allEdges = useMemo( () => (showA2AEdges ? [...edges, ...a2aEdges] : edges), - [edges, a2aEdges, showA2AEdges] + [edges, a2aEdges, showA2AEdges], ); const onNodesChange = useCanvasStore((s) => s.onNodesChange); - const savePosition = useCanvasStore((s) => s.savePosition); const selectNode = useCanvasStore((s) => s.selectNode); const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); - const setDragOverNode = useCanvasStore((s) => s.setDragOverNode); - const nestNode = useCanvasStore((s) => s.nestNode); - const isDescendant = useCanvasStore((s) => s.isDescendant); - const dragStartParentRef = useRef(null); - const { getIntersectingNodes } = useReactFlow(); - const onNodeDragStart: OnNodeDrag> = useCallback( - (_event, node) => { - dragStartParentRef.current = (node.data as WorkspaceNodeData).parentId; - }, - [] - ); + // Drag / nest lifecycle — handlers, pending-nest state, confirm/cancel. + const { + onNodeDragStart, + onNodeDrag, + onNodeDragStop, + pendingNest, + confirmNest, + cancelNest, + } = useDragHandlers(); - const onNodeDrag: OnNodeDrag> = useCallback( - (_event, node) => { - // Only consider nodes within a proximity threshold as nest targets. - // Without this check, getIntersectingNodes returns any node whose bounding - // boxes overlap — which can be hundreds of pixels away on a sparse canvas, - // causing accidental nesting when the user drags a node across the board. - const thresholdPx = 100; - const threshold = thresholdPx * thresholdPx; // compare squared distances - let nearest: { id: string; dist: number } | null = null; - for (const candidate of getIntersectingNodes(node)) { - if (candidate.id === node.id || isDescendant(node.id, candidate.id)) continue; - const dx = candidate.position.x - node.position.x; - const dy = candidate.position.y - node.position.y; - const dist2 = dx * dx + dy * dy; - if (dist2 <= threshold && (!nearest || dist2 < nearest.dist)) { - nearest = { id: candidate.id, dist: dist2 }; - } - } - setDragOverNode(nearest?.id ?? null); - }, - [getIntersectingNodes, isDescendant, setDragOverNode] - ); + // Window-level keyboard shortcuts (Esc, Enter, Shift+Enter, Cmd+]/[, Z). + useKeyboardShortcuts(); + + // Pan-to-node / zoom-to-team CustomEvent listeners + viewport save. + const { onMoveEnd } = useCanvasViewport(); - // Confirmation dialog state for structure changes - const [pendingNest, setPendingNest] = useState<{ nodeId: string; targetId: string | null; nodeName: string; targetName: string } | null>(null); // Delete-confirmation lives in the store so the dialog survives ContextMenu // unmounting — the prior local-in-ContextMenu state raced with the menu's - // outside-click handler (the portal-rendered Confirm button counted as - // "outside" and closed the menu, killing the dialog mid-click). + // outside-click handler. const pendingDelete = useCanvasStore((s) => s.pendingDelete); const setPendingDelete = useCanvasStore((s) => s.setPendingDelete); const removeNode = useCanvasStore((s) => s.removeNode); @@ -129,48 +104,6 @@ function CanvasInner() { } }, [pendingDelete, setPendingDelete, removeNode]); - // Cascade guard: include child count in the warning message when the workspace - // has children, so the user understands the blast radius before clicking Delete All. - const cascadeMessage = pendingDelete?.hasChildren - ? `⚠️ Deleting "${pendingDelete.name}" will permanently delete all child workspaces and their data. This cannot be undone.` - : null; - - const onNodeDragStop: OnNodeDrag> = useCallback( - (_event, node) => { - const { dragOverNodeId, nodes: allNodes } = useCanvasStore.getState(); - setDragOverNode(null); - - const nodeName = (node.data as WorkspaceNodeData).name; - - if (dragOverNodeId) { - const targetNode = allNodes.find((n) => n.id === dragOverNodeId); - const targetName = targetNode?.data.name || "Unknown"; - setPendingNest({ nodeId: node.id, targetId: dragOverNodeId, nodeName, targetName }); - } else { - const currentParentId = (node.data as WorkspaceNodeData).parentId; - if (currentParentId) { - const parentNode = allNodes.find((n) => n.id === currentParentId); - const parentName = parentNode?.data.name || "Unknown"; - setPendingNest({ nodeId: node.id, targetId: null, nodeName, targetName: parentName }); - } - } - - savePosition(node.id, node.position.x, node.position.y); - }, - [savePosition, setDragOverNode] - ); - - const confirmNest = useCallback(() => { - if (pendingNest) { - nestNode(pendingNest.nodeId, pendingNest.targetId); - setPendingNest(null); - } - }, [pendingNest, nestNode]); - - const cancelNest = useCallback(() => { - setPendingNest(null); - }, []); - const onPaneClick = useCallback(() => { selectNode(null); const state = useCanvasStore.getState(); @@ -178,123 +111,14 @@ function CanvasInner() { state.clearSelection(); }, [selectNode]); - // Team zoom-in: double-click a team node to zoom to its children - const { fitBounds, fitView } = useReactFlow(); - - // Pan to newly deployed workspace. - // Uses fitView({ nodes }) so the viewport adapts to any current zoom level - // instead of forcing zoom=1 (which was jarring when the user was zoomed out). - const panTimerRef = useRef>(undefined); - useEffect(() => { - const handler = (e: Event) => { - const { nodeId } = (e as CustomEvent<{ nodeId: string }>).detail; - // Small delay so ReactFlow has time to measure the newly rendered node - clearTimeout(panTimerRef.current); - panTimerRef.current = setTimeout(() => { - fitView({ nodes: [{ id: nodeId }], duration: 400, padding: 0.3 }); - }, 100); - }; - window.addEventListener("molecule:pan-to-node", handler); - return () => { - window.removeEventListener("molecule:pan-to-node", handler); - clearTimeout(panTimerRef.current); - }; - }, [fitView]); - useEffect(() => { - const handler = (e: Event) => { - const { nodeId } = (e as CustomEvent).detail; - const state = useCanvasStore.getState(); - const children = state.nodes.filter((n) => n.data.parentId === nodeId); - if (children.length === 0) return; - - const parent = state.nodes.find((n) => n.id === nodeId); - const allNodes = parent ? [parent, ...children] : children; - - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const n of allNodes) { - minX = Math.min(minX, n.position.x); - minY = Math.min(minY, n.position.y); - maxX = Math.max(maxX, n.position.x + 260); - maxY = Math.max(maxY, n.position.y + 120); - } - - fitBounds( - { x: minX - 50, y: minY - 50, width: maxX - minX + 100, height: maxY - minY + 100 }, - { padding: 0.2, duration: 500 } - ); - }; - window.addEventListener("molecule:zoom-to-team", handler); - return () => window.removeEventListener("molecule:zoom-to-team", handler); - }, [fitBounds]); - - // Keyboard shortcuts - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") { - const state = useCanvasStore.getState(); - if (state.contextMenu) { - state.closeContextMenu(); - } else if (state.selectedNodeIds.size > 0) { - state.clearSelection(); - } else if (state.selectedNodeId) { - state.selectNode(null); - } - } - - // Z — keyboard equivalent for double-click zoom-to-team (WCAG 2.1.1) - if (e.key === "z" || e.key === "Z") { - const tag = (e.target as HTMLElement).tagName; - if ( - tag === "INPUT" || - tag === "TEXTAREA" || - tag === "SELECT" || - (e.target as HTMLElement).isContentEditable - ) - return; - const state = useCanvasStore.getState(); - const selectedId = state.selectedNodeId; - if (!selectedId) return; - const hasChildren = state.nodes.some((n) => n.data.parentId === selectedId); - if (hasChildren) { - window.dispatchEvent( - new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: selectedId } }) - ); - } - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, []); - - const saveViewport = useCanvasStore((s) => s.saveViewport); const viewport = useCanvasStore((s) => s.viewport); - const saveTimerRef = useRef>(undefined); - - // Cleanup debounced save timer on unmount - useEffect(() => { - return () => clearTimeout(saveTimerRef.current); - }, []); - - const onMoveEnd = useCallback( - (_event: unknown, vp: { x: number; y: number; zoom: number }) => { - // Debounce viewport saves to avoid spamming the API - clearTimeout(saveTimerRef.current); - saveTimerRef.current = setTimeout(() => { - saveViewport(vp.x, vp.y, vp.zoom); - }, 1000); - }, - [saveViewport] - ); - const defaultViewport = useMemo( () => ({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }), // Only use the initial viewport — don't re-render on every save // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [], ); - // Determine which workspace ID to use for global settings. - // Fall back to "global" when no specific node is selected. const settingsWorkspaceId = selectedNodeId ?? "global"; return ( @@ -306,112 +130,118 @@ function CanvasInner() { Skip to canvas
- - + + + { + // Parents show as a filled region — hierarchy visible at + // a glance in the minimap without needing to zoom. + const hasChildren = nodes.some((n) => n.parentId === node.id); + if (hasChildren) return "#3b82f6"; + const status = (node.data as Record)?.status; + switch (status) { + case "online": + return "#34d399"; + case "offline": + return "#52525b"; + case "degraded": + return "#fbbf24"; + case "failed": + return "#f87171"; + case "provisioning": + return "#38bdf8"; + default: + return "#3f3f46"; + } + }} + nodeStrokeColor={(node) => { + const hasChildren = nodes.some((n) => n.parentId === node.id); + return hasChildren ? "#60a5fa" : "transparent"; + }} + nodeStrokeWidth={2} + nodeBorderRadius={4} + /> + + + + {/* Screen-reader live region: announces workspace count on canvas load or change */} +
+ {nodes.filter((n) => !n.parentId).length === 0 + ? "No workspaces on canvas" + : `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`} +
+ + {nodes.length === 0 && } + + + + + + + + + + + + {!selectedNodeId && } + + + - setPendingDelete(null)} /> - { - const status = (node.data as Record)?.status; - switch (status) { - case "online": - return "#34d399"; - case "offline": - return "#52525b"; - case "degraded": - return "#fbbf24"; - case "failed": - return "#f87171"; - case "provisioning": - return "#38bdf8"; - default: - return "#3f3f46"; - } - }} - nodeStrokeWidth={0} - nodeBorderRadius={4} - /> - - {/* Screen-reader live region: announces workspace count when canvas loads or changes */} -
- {nodes.filter((n) => !n.data.parentId).length === 0 - ? "No workspaces on canvas" - : `${nodes.filter((n) => !n.data.parentId).length} workspace${nodes.filter((n) => !n.data.parentId).length !== 1 ? "s" : ""} on canvas`} -
- - {nodes.length === 0 && } - - - - - - - - - - - - {!selectedNodeId && } - - - {/* Confirmation dialog for structure changes */} - - - {/* Confirmation dialog for workspace delete — driven by store */} - setPendingDelete(null)} - /> - - {/* Settings Panel — global secrets management drawer */} - - + +
); diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index d87e62b3..475e8319 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -202,15 +202,22 @@ export function ContextMenu() { closeContextMenu(); }, [contextMenu, closeContextMenu]); + const setCollapsed = useCanvasStore((s) => s.setCollapsed); const handleCollapse = useCallback(async () => { if (!contextMenu) return; + const nodeId = contextMenu.nodeId; + const wasCollapsed = !!contextMenu.nodeData.collapsed; + // Optimistic local flip so the card shrinks/expands immediately. + // Descendants' hidden flags are toggled atomically by the store. + setCollapsed(nodeId, !wasCollapsed); try { - await api.post(`/workspaces/${contextMenu.nodeId}/collapse`, {}); + await api.patch(`/workspaces/${nodeId}`, { collapsed: !wasCollapsed }); } catch (e) { + setCollapsed(nodeId, wasCollapsed); showToast("Collapse failed", "error"); } closeContextMenu(); - }, [contextMenu, closeContextMenu]); + }, [contextMenu, setCollapsed, closeContextMenu]); const handleRemoveFromTeam = useCallback(async () => { if (!contextMenu) return; @@ -223,6 +230,13 @@ export function ContextMenu() { closeContextMenu(); }, [contextMenu, nestNode, closeContextMenu]); + const arrangeChildren = useCanvasStore((s) => s.arrangeChildren); + const handleArrangeChildren = useCallback(() => { + if (!contextMenu) return; + arrangeChildren(contextMenu.nodeId); + closeContextMenu(); + }, [contextMenu, arrangeChildren, closeContextMenu]); + const handleZoomToTeam = useCallback(() => { if (!contextMenu) return; window.dispatchEvent( @@ -250,7 +264,12 @@ export function ContextMenu() { : []), ...(hasChildren ? [ - { label: "Collapse Team", icon: "◁", action: handleCollapse }, + { label: "Arrange Children", icon: "▦", action: handleArrangeChildren }, + { + label: contextMenu.nodeData.collapsed ? "Expand Team" : "Collapse Team", + icon: contextMenu.nodeData.collapsed ? "▽" : "◁", + action: handleCollapse, + }, { label: "Zoom to Team", icon: "⊕", action: handleZoomToTeam }, ] : [{ label: "Expand to Team", icon: "▷", action: handleExpand }]), diff --git a/canvas/src/components/TemplatePalette.tsx b/canvas/src/components/TemplatePalette.tsx index 710d01b1..3f67bcba 100644 --- a/canvas/src/components/TemplatePalette.tsx +++ b/canvas/src/components/TemplatePalette.tsx @@ -400,6 +400,11 @@ export function TemplatePalette() {
+ {/* Org templates live INSIDE the scroll container so an + * expanded list (15+ entries) is reachable instead of + * overflowing the fixed footer below. */} + + {loading && (
@@ -467,7 +472,6 @@ export function TemplatePalette() {
-
+ ); } -const MAX_NESTING_DEPTH = 3; - /** Count all descendants (children + grandchildren + ...) */ function countDescendants(nodeId: string, allNodes: Node[], visited = new Set()): number { if (visited.has(nodeId)) return 0; @@ -300,192 +314,6 @@ function countDescendants(nodeId: string, allNodes: Node[], v return count; } -/** Subscribes to allNodes only when children exist — isolates re-renders from parent */ -function EmbeddedTeam({ members, depth, onSelect, onExtract }: { - members: Node[]; - depth: number; - onSelect: (id: string) => void; - onExtract: (id: string) => void; -}) { - const allNodes = useCanvasStore((s) => s.nodes); - // Use grid layout at depth 0 when there are multiple members (departments side-by-side) - const useGrid = depth === 0 && members.length >= 2; - return ( -
-
Team Members
-
- {members.map((child) => ( - - ))} -
-
- ); -} - -/** Recursive mini-card — mirrors parent card layout at smaller scale */ -function TeamMemberChip({ - node, - allNodes, - depth, - onSelect, - onExtract, -}: { - node: Node; - allNodes: Node[]; - depth: number; - onSelect: (id: string) => void; - onExtract: (id: string) => void; -}) { - const { data } = node; - const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline; - const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" }; - const isOnline = data.status === "online"; - const skills = getSkillNames(data.agentCard); - - const subChildren = useMemo( - () => allNodes.filter((n) => n.data.parentId === node.id), - [allNodes, node.id] - ); - const hasSubChildren = subChildren.length > 0; - const descendantCount = useMemo( - () => hasSubChildren ? countDescendants(node.id, allNodes) : 0, - [allNodes, node.id, hasSubChildren] - ); - - return ( -
{ - e.stopPropagation(); - onSelect(node.id); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - onSelect(node.id); - } - }} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - useCanvasStore.getState().openContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id, nodeData: data }); - }} - > - {/* Status gradient bar */} -
- -
- {/* Header: name + badges + extract */} -
-
-
- - {data.name} - -
-
- {hasSubChildren && ( - - {descendantCount} - - )} - - {tierCfg.label} - - -
-
- - {/* Role */} - {data.role && ( -
{data.role}
- )} - - {/* Skills */} - {skills.length > 0 && ( -
- {skills.slice(0, 3).map((skill) => ( - - {skill} - - ))} - {skills.length > 3 && ( - +{skills.length - 3} - )} -
- )} - - {/* Status + active tasks row */} -
- {data.status !== "online" ? ( - - {statusCfg.label} - - ) :
} - {data.activeTasks > 0 && ( -
-
- - {data.activeTasks} - -
- )} -
- - {/* Current task banner for sub-agents */} - {data.currentTask && ( - -
-
- {data.currentTask} -
- - )} - - {/* Recursive sub-children rendered inside this card */} - {hasSubChildren && depth < MAX_NESTING_DEPTH && ( -
-
Team
-
= 2 ? "grid grid-cols-2 gap-1" : "space-y-1"}> - {subChildren.map((sub) => ( - - ))} -
-
- )} -
-
- ); -} function getSkillNames(agentCard: Record | null): string[] { if (!agentCard) return []; diff --git a/canvas/src/components/__tests__/WorkspaceNode.a11y.test.tsx b/canvas/src/components/__tests__/WorkspaceNode.a11y.test.tsx deleted file mode 100644 index 48c5cb35..00000000 --- a/canvas/src/components/__tests__/WorkspaceNode.a11y.test.tsx +++ /dev/null @@ -1,202 +0,0 @@ -// @vitest-environment jsdom -/** - * WorkspaceNode a11y tests — issue #831 - * - * Covers the TeamMemberChip sub-component (rendered inside a parent workspace - * node when that node has children): - * - role="button" is present - * - aria-label="Select " is present - * - pressing Enter triggers onSelect with the child's id - * - pressing Space triggers onSelect with the child's id - * - the eject button has aria-label="Extract from team" - */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; - -afterEach(() => { - cleanup(); -}); - -// ── Mock @xyflow/react (Handles) ────────────────────────────────────────────── -vi.mock("@xyflow/react", () => ({ - Handle: () => null, - Position: { Top: "top", Bottom: "bottom" }, -})); - -// ── Mock Tooltip (passthrough) ──────────────────────────────────────────────── -vi.mock("@/components/Tooltip", () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, -})); - -// ── Mock Toaster ────────────────────────────────────────────────────────────── -vi.mock("@/components/Toaster", () => ({ - showToast: vi.fn(), -})); - -// ── Mock design tokens ──────────────────────────────────────────────────────── -vi.mock("@/lib/design-tokens", () => ({ - STATUS_CONFIG: { - online: { - dot: "bg-emerald-400", - glow: "", - bar: "from-emerald-950/30", - label: "Online", - }, - offline: { - dot: "bg-zinc-500", - glow: "", - bar: "from-zinc-900", - label: "Offline", - }, - degraded: { - dot: "bg-amber-400", - glow: "", - bar: "from-amber-950/30", - label: "Degraded", - }, - provisioning: { - dot: "bg-sky-400", - glow: "", - bar: "from-sky-950/30", - label: "Provisioning", - }, - failed: { - dot: "bg-red-400", - glow: "", - bar: "from-red-950/30", - label: "Failed", - }, - }, - TIER_CONFIG: { - 1: { label: "T1", color: "text-zinc-400 bg-zinc-800" }, - 2: { label: "T2", color: "text-zinc-400 bg-zinc-800" }, - 3: { label: "T3", color: "text-zinc-400 bg-zinc-800" }, - }, -})); - -// ── Store state with a parent + one child ──────────────────────────────────── - -const mockSelectNode = vi.fn(); -const mockOpenContextMenu = vi.fn(); -const mockNestNode = vi.fn(); - -const PARENT_ID = "ws-parent"; -const CHILD_ID = "ws-child"; - -const PARENT_DATA = { - name: "Parent Workspace", - status: "online", - tier: 1 as const, - role: "Manager", - parentId: null, - needsRestart: false, - currentTask: null, - activeTasks: 0, - agentCard: null, - runtime: "langgraph", - lastSampleError: null, -}; - -const CHILD_DATA = { - name: "Child Workspace", - status: "online", - tier: 1 as const, - role: "Worker", - parentId: PARENT_ID, - needsRestart: false, - currentTask: null, - activeTasks: 0, - agentCard: null, - runtime: "langgraph", - lastSampleError: null, -}; - -const ALL_NODES = [ - { id: PARENT_ID, position: { x: 0, y: 0 }, data: PARENT_DATA }, - { id: CHILD_ID, position: { x: 0, y: 0 }, data: CHILD_DATA }, -]; - -const mockStoreState = { - nodes: ALL_NODES, - selectedNodeId: null, - dragOverNodeId: null, - selectNode: mockSelectNode, - openContextMenu: mockOpenContextMenu, - nestNode: mockNestNode, - restartWorkspace: vi.fn(() => Promise.resolve()), - setPanelTab: vi.fn(), - selectedNodeIds: new Set(), - toggleNodeSelection: vi.fn(), -}; - -vi.mock("@/store/canvas", () => ({ - useCanvasStore: Object.assign( - vi.fn((selector: (s: typeof mockStoreState) => unknown) => - selector(mockStoreState) - ), - { getState: () => mockStoreState } - ), -})); - -// ── Import component AFTER mocks ────────────────────────────────────────────── -import { WorkspaceNode } from "../WorkspaceNode"; - -// ── Helper ──────────────────────────────────────────────────────────────────── - -function renderParentNode() { - // WorkspaceNode's full NodeProps has many optional fields; we only need id+data - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return render(); -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -describe("WorkspaceNode — TeamMemberChip a11y (issue #831)", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("TeamMemberChip renders with role='button'", () => { - renderParentNode(); - // The parent WorkspaceNode div is role=button (aria-label contains the name), - // and the chip is a separate role=button with aria-label starting with "Select" - const chip = screen.getByRole("button", { - name: "Select Child Workspace", - }); - expect(chip).toBeTruthy(); - }); - - it("TeamMemberChip has aria-label='Select '", () => { - renderParentNode(); - const chip = screen.getByRole("button", { - name: "Select Child Workspace", - }); - expect(chip.getAttribute("aria-label")).toBe("Select Child Workspace"); - }); - - it("pressing Enter on TeamMemberChip calls selectNode with the child's id", () => { - renderParentNode(); - const chip = screen.getByRole("button", { - name: "Select Child Workspace", - }); - fireEvent.keyDown(chip, { key: "Enter" }); - expect(mockSelectNode).toHaveBeenCalledWith(CHILD_ID); - }); - - it("pressing Space on TeamMemberChip calls selectNode with the child's id", () => { - renderParentNode(); - const chip = screen.getByRole("button", { - name: "Select Child Workspace", - }); - fireEvent.keyDown(chip, { key: " " }); - expect(mockSelectNode).toHaveBeenCalledWith(CHILD_ID); - }); - - it("eject button has aria-label='Extract from team'", () => { - renderParentNode(); - const ejectBtn = screen.getByRole("button", { - name: "Extract Child Workspace from team", - }); - expect(ejectBtn).toBeTruthy(); - }); -}); diff --git a/canvas/src/components/__tests__/WorkspaceNode.eject.test.tsx b/canvas/src/components/__tests__/WorkspaceNode.eject.test.tsx deleted file mode 100644 index 691bc2cd..00000000 --- a/canvas/src/components/__tests__/WorkspaceNode.eject.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -// @vitest-environment jsdom -/** - * Tests for issue #854 — TeamMemberChip eject button: - * - aria-label must be dynamic: `Extract ${childName} from team` - * - title must be dynamic: `Extract ${childName} from team` - * - EjectIcon svg must carry aria-hidden="true" - */ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, cleanup } from "@testing-library/react"; -import type { Node } from "@xyflow/react"; -import type { WorkspaceNodeData } from "@/store/canvas"; - -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); -}); - -// ── Mock @xyflow/react ───────────────────────────────────────────────────────── -vi.mock("@xyflow/react", () => ({ - Handle: () => null, - Position: { Bottom: "bottom", Top: "top" }, - useReactFlow: vi.fn(), -})); - -// ── Mock Toaster ─────────────────────────────────────────────────────────────── -vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() })); - -// ── Mock Tooltip ─────────────────────────────────────────────────────────────── -vi.mock("@/components/Tooltip", () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, -})); - -// ── Mock design tokens ───────────────────────────────────────────────────────── -vi.mock("@/lib/design-tokens", () => ({ - STATUS_CONFIG: { - online: { label: "Online", dot: "bg-emerald-400", bar: "from-emerald-500/10" }, - offline: { label: "Offline", dot: "bg-zinc-600", bar: "from-zinc-700/10" }, - provisioning: { label: "Provisioning", dot: "bg-sky-400", bar: "from-sky-500/10" }, - degraded: { label: "Degraded", dot: "bg-amber-400", bar: "from-amber-500/10" }, - failed: { label: "Failed", dot: "bg-red-400", bar: "from-red-500/10" }, - paused: { label: "Paused", dot: "bg-zinc-500", bar: "from-zinc-600/10" }, - }, - TIER_CONFIG: { - 1: { label: "T1", color: "text-zinc-400 bg-zinc-800" }, - 2: { label: "T2", color: "text-blue-400 bg-blue-900/40" }, - }, -})); - -// ── Canvas store mock state ──────────────────────────────────────────────────── -const PARENT_ID = "parent-ws"; -const CHILD_ID = "child-ws"; -const CHILD_NAME = "Child Workspace"; - -function makeNodeData(overrides: Partial = {}): WorkspaceNodeData { - return { - name: "Test WS", - role: "agent", - tier: 1, - status: "online", - agentCard: null, - url: "http://localhost:9000", - parentId: null, - activeTasks: 0, - lastErrorRate: 0, - lastSampleError: "", - uptimeSeconds: 60, - currentTask: "", - collapsed: false, - runtime: "", - needsRestart: false, - budgetLimit: null, - ...overrides, - } as WorkspaceNodeData; -} - -const parentNodeData = makeNodeData({ name: "Parent WS", parentId: null }); -const childNodeData = makeNodeData({ name: CHILD_NAME, parentId: PARENT_ID }); - -const allNodes: Node[] = [ - { id: PARENT_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: parentNodeData }, - { id: CHILD_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: childNodeData, hidden: true }, -]; - -// Build a selector-compatible mock of useCanvasStore -const mockStoreState = { - nodes: allNodes, - edges: [], - selectedNodeId: null, - panelTab: "chat", - dragOverNodeId: null, - contextMenu: null, - searchOpen: false, - viewport: { x: 0, y: 0, zoom: 1 }, - selectNode: vi.fn(), - openContextMenu: vi.fn(), - nestNode: vi.fn(), - isDescendant: vi.fn(() => false), - restartWorkspace: vi.fn(), - setPanelTab: vi.fn(), - selectedNodeIds: new Set(), - toggleNodeSelection: vi.fn(), -}; - -vi.mock("@/store/canvas", () => ({ - useCanvasStore: Object.assign( - vi.fn((selector: (s: typeof mockStoreState) => unknown) => - selector(mockStoreState) - ), - { getState: () => mockStoreState } - ), -})); - -// ── Mock zustand/react/shallow ───────────────────────────────────────────────── -vi.mock("zustand/react/shallow", () => ({ - useShallow: (fn: (s: typeof mockStoreState) => unknown) => fn, -})); - -// ── Import component AFTER mocks ─────────────────────────────────────────────── -import { WorkspaceNode } from "../WorkspaceNode"; - -// ── Helpers ──────────────────────────────────────────────────────────────────── -function renderParentNode() { - return render( - - ); -} - -// ── Tests ────────────────────────────────────────────────────────────────────── - -describe("TeamMemberChip eject button — aria-label (issue #854)", () => { - it("eject button has a dynamic aria-label containing the child workspace name", () => { - const { container } = renderParentNode(); - const buttons = container.querySelectorAll("button"); - const ejectBtn = Array.from(buttons).find( - (b) => b.getAttribute("aria-label")?.includes("Extract") && b.getAttribute("aria-label")?.includes("from team") - ); - expect(ejectBtn).toBeTruthy(); - expect(ejectBtn?.getAttribute("aria-label")).toBe(`Extract ${CHILD_NAME} from team`); - }); -}); - -describe("TeamMemberChip eject button — title tooltip (issue #854)", () => { - it("eject button has a dynamic title tooltip containing the child workspace name", () => { - const { container } = renderParentNode(); - const buttons = container.querySelectorAll("button"); - const ejectBtn = Array.from(buttons).find( - (b) => b.getAttribute("title")?.includes("Extract") && b.getAttribute("title")?.includes("from team") - ); - expect(ejectBtn).toBeTruthy(); - expect(ejectBtn?.getAttribute("title")).toBe(`Extract ${CHILD_NAME} from team`); - }); - - it("aria-label and title are identical (both use child workspace name)", () => { - const { container } = renderParentNode(); - const buttons = container.querySelectorAll("button"); - const ejectBtn = Array.from(buttons).find( - (b) => b.getAttribute("aria-label")?.startsWith("Extract") - ); - expect(ejectBtn).toBeTruthy(); - expect(ejectBtn?.getAttribute("aria-label")).toBe(ejectBtn?.getAttribute("title")); - }); -}); - -describe("TeamMemberChip eject button — aria-hidden on EjectIcon (issue #854)", () => { - it("EjectIcon svg has aria-hidden='true' to prevent AT double-announcement", () => { - const { container } = renderParentNode(); - const buttons = container.querySelectorAll("button"); - const ejectBtn = Array.from(buttons).find( - (b) => b.getAttribute("aria-label")?.startsWith("Extract") - ); - expect(ejectBtn).toBeTruthy(); - const svg = ejectBtn?.querySelector("svg"); - expect(svg).toBeTruthy(); - expect(svg?.getAttribute("aria-hidden")).toBe("true"); - }); -}); diff --git a/canvas/src/components/__tests__/ZoomShortcut.test.tsx b/canvas/src/components/__tests__/ZoomShortcut.test.tsx index 6b227c0f..85858de9 100644 --- a/canvas/src/components/__tests__/ZoomShortcut.test.tsx +++ b/canvas/src/components/__tests__/ZoomShortcut.test.tsx @@ -71,11 +71,14 @@ describe("Toolbar help panel — zoom shortcut entry", () => { expect(src).toContain("Zoom canvas to fit a team node"); }); - it("Canvas.tsx Z key handler guards against input elements", async () => { + it("Keyboard shortcuts hook guards against input elements", async () => { const { readFileSync } = await import("fs"); const { join } = await import("path"); + // After the canvas split (commit c5abed98 → f3423a51 series), the + // Z-key / hierarchy / zoom shortcuts moved out of Canvas.tsx into + // the useKeyboardShortcuts hook under src/components/canvas/. const src = readFileSync( - join(__dirname, "../../components/Canvas.tsx"), + join(__dirname, "../../components/canvas/useKeyboardShortcuts.ts"), "utf8" ); expect(src).toContain('e.key === "z" || e.key === "Z"'); diff --git a/canvas/src/components/canvas/DropTargetBadge.tsx b/canvas/src/components/canvas/DropTargetBadge.tsx new file mode 100644 index 00000000..13c0f7d4 --- /dev/null +++ b/canvas/src/components/canvas/DropTargetBadge.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useReactFlow } from "@xyflow/react"; +import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; +import { + defaultChildSlot, + CHILD_DEFAULT_HEIGHT, + CHILD_DEFAULT_WIDTH, +} from "@/store/canvas-topology"; + +/** + * Floating affordance that tracks the current drag target. Two visuals + * are layered on top of React Flow, both in screen space: + * + * 1. Ghost preview — dashed outline at the next default grid slot + * inside the target parent. Whimsical-style: users see exactly + * where the card will land before releasing. + * 2. Text badge — "Drop into: " floating above the target. The + * coloured outline alone is ambiguous on dense canvases; spelling + * the name out is the Mural pattern. + * + * Colour alone isn't an accessible cue, so the pair (outline + label) + * is deliberate. + */ +export function DropTargetBadge() { + const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId); + const targetName = useCanvasStore((s) => { + if (!s.dragOverNodeId) return null; + const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId); + return (n?.data as WorkspaceNodeData | undefined)?.name ?? null; + }); + const childCount = useCanvasStore((s) => + !s.dragOverNodeId + ? 0 + : s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length, + ); + const { getInternalNode, flowToScreenPosition } = useReactFlow(); + if (!dragOverNodeId || !targetName) return null; + const internal = getInternalNode(dragOverNodeId); + if (!internal) return null; + const abs = internal.internals.positionAbsolute; + const w = internal.measured?.width ?? 220; + const h = internal.measured?.height ?? 120; + const badge = flowToScreenPosition({ x: abs.x + w / 2, y: abs.y }); + + const slot = defaultChildSlot(childCount); + const slotTL = flowToScreenPosition({ x: abs.x + slot.x, y: abs.y + slot.y }); + const slotBR = flowToScreenPosition({ + x: abs.x + slot.x + CHILD_DEFAULT_WIDTH, + y: abs.y + slot.y + CHILD_DEFAULT_HEIGHT, + }); + // Clip: don't draw the ghost if its rect falls entirely outside the + // parent (can happen when a parent is smaller than one default slot). + const parentTL = flowToScreenPosition({ x: abs.x, y: abs.y }); + const parentBR = flowToScreenPosition({ x: abs.x + w, y: abs.y + h }); + const ghostVisible = + slotBR.x > parentTL.x && + slotTL.x < parentBR.x && + slotBR.y > parentTL.y && + slotTL.y < parentBR.y; + + return ( + <> + {ghostVisible && ( +
+ )} +
+ Drop into: {targetName} +
+ + ); +} diff --git a/canvas/src/components/canvas/dragUtils.ts b/canvas/src/components/canvas/dragUtils.ts new file mode 100644 index 00000000..a0e5959a --- /dev/null +++ b/canvas/src/components/canvas/dragUtils.ts @@ -0,0 +1,74 @@ +import type { useReactFlow } from "@xyflow/react"; +import { useCanvasStore } from "@/store/canvas"; + +/** + * Hysteresis threshold for drag-out detach. A child only un-nests from + * its parent once at least this fraction of its bounding box lies + * outside the parent's bbox — a twitchy release 1px past the edge stays + * nested. Miro / tldraw use roughly 20-30%; 20% feels responsive. + */ +export const DETACH_FRACTION = 0.2; + +type InternalNode = ReturnType["getInternalNode"]>; +type GetInternalNode = (id: string) => InternalNode; + +/** + * True when the child has moved far enough outside its parent's bbox + * that the gesture is unambiguously an un-nest. Returns true when we + * can't measure either node (conservative fall-back matches the + * original behaviour). + */ +export function shouldDetach( + childId: string, + parentId: string, + getInternalNode: GetInternalNode, +): boolean { + const c = getInternalNode(childId); + const p = getInternalNode(parentId); + if (!c || !p) return true; + const cw = c.measured?.width ?? c.width ?? 220; + const ch = c.measured?.height ?? c.height ?? 120; + const pw = p.measured?.width ?? p.width ?? 220; + const ph = p.measured?.height ?? p.height ?? 120; + const cx = c.internals.positionAbsolute; + const px = p.internals.positionAbsolute; + const overlapW = + Math.max(0, Math.min(cx.x + cw, px.x + pw) - Math.max(cx.x, px.x)); + const overlapH = + Math.max(0, Math.min(cx.y + ch, px.y + ph) - Math.max(cx.y, px.y)); + const outsideFractionX = 1 - overlapW / cw; + const outsideFractionY = 1 - overlapH / ch; + return outsideFractionX > DETACH_FRACTION || outsideFractionY > DETACH_FRACTION; +} + +/** + * Snap a child back so its bbox is fully inside the parent's bounds. + * Called on drag-stop when the user drifted slightly past the edge + * without holding Alt or Cmd — the canvas treats the gesture as a + * plain move rather than an un-nest. + */ +export function clampChildIntoParent( + childId: string, + parentId: string, + getInternalNode: GetInternalNode, +) { + const c = getInternalNode(childId); + const p = getInternalNode(parentId); + if (!c || !p) return; + const cw = c.measured?.width ?? c.width ?? 220; + const ch = c.measured?.height ?? c.height ?? 120; + const pw = p.measured?.width ?? p.width ?? 220; + const ph = p.measured?.height ?? p.height ?? 120; + const { nodes } = useCanvasStore.getState(); + const cur = nodes.find((n) => n.id === childId); + if (!cur) return; + const rel = cur.position; + const clampedX = Math.max(0, Math.min(rel.x, pw - cw)); + const clampedY = Math.max(0, Math.min(rel.y, ph - ch)); + if (clampedX === rel.x && clampedY === rel.y) return; + useCanvasStore.setState({ + nodes: nodes.map((n) => + n.id === childId ? { ...n, position: { x: clampedX, y: clampedY } } : n, + ), + }); +} diff --git a/canvas/src/components/canvas/useCanvasViewport.ts b/canvas/src/components/canvas/useCanvasViewport.ts new file mode 100644 index 00000000..c7ce9169 --- /dev/null +++ b/canvas/src/components/canvas/useCanvasViewport.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { useReactFlow } from "@xyflow/react"; +import { useCanvasStore } from "@/store/canvas"; +import { + CHILD_DEFAULT_HEIGHT, + CHILD_DEFAULT_WIDTH, +} from "@/store/canvas-topology"; + +/** + * Wires the two canvas-wide CustomEvent listeners and the viewport + * save/restore bookkeeping so Canvas.tsx doesn't have to. + * + * - `molecule:pan-to-node` — scroll viewport onto a specific node + * without forcing a specific zoom level (fitView adapts to current). + * - `molecule:zoom-to-team` — fit the viewport to a parent + its + * direct children, with a small padding. + * + * Also returns an `onMoveEnd` handler that debounces viewport saves so + * the backend isn't spammed with pans. + */ +export function useCanvasViewport() { + const { fitBounds, fitView } = useReactFlow(); + const saveViewport = useCanvasStore((s) => s.saveViewport); + const saveTimerRef = useRef>(undefined); + const panTimerRef = useRef>(undefined); + + useEffect(() => { + return () => { + clearTimeout(saveTimerRef.current); + clearTimeout(panTimerRef.current); + }; + }, []); + + // Pan to a newly deployed / targeted workspace. 100ms delay so React + // Flow has time to measure a just-rendered node. + useEffect(() => { + const handler = (e: Event) => { + const { nodeId } = (e as CustomEvent<{ nodeId: string }>).detail; + clearTimeout(panTimerRef.current); + panTimerRef.current = setTimeout(() => { + fitView({ nodes: [{ id: nodeId }], duration: 400, padding: 0.3 }); + }, 100); + }; + window.addEventListener("molecule:pan-to-node", handler); + return () => window.removeEventListener("molecule:pan-to-node", handler); + }, [fitView]); + + // Zoom to a team: fit the parent + its direct children in view. + useEffect(() => { + const handler = (e: Event) => { + const { nodeId } = (e as CustomEvent).detail; + const state = useCanvasStore.getState(); + const children = state.nodes.filter((n) => n.data.parentId === nodeId); + if (children.length === 0) return; + const parent = state.nodes.find((n) => n.id === nodeId); + const allNodes = parent ? [parent, ...children] : children; + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const n of allNodes) { + minX = Math.min(minX, n.position.x); + minY = Math.min(minY, n.position.y); + maxX = Math.max(maxX, n.position.x + CHILD_DEFAULT_WIDTH); + maxY = Math.max(maxY, n.position.y + CHILD_DEFAULT_HEIGHT); + } + + fitBounds( + { + x: minX - 50, + y: minY - 50, + width: maxX - minX + 100, + height: maxY - minY + 100, + }, + { padding: 0.2, duration: 500 }, + ); + }; + window.addEventListener("molecule:zoom-to-team", handler); + return () => window.removeEventListener("molecule:zoom-to-team", handler); + }, [fitBounds]); + + const onMoveEnd = useCallback( + (_event: unknown, vp: { x: number; y: number; zoom: number }) => { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + saveViewport(vp.x, vp.y, vp.zoom); + }, 1000); + }, + [saveViewport], + ); + + return { onMoveEnd }; +} diff --git a/canvas/src/components/canvas/useDragHandlers.ts b/canvas/src/components/canvas/useDragHandlers.ts new file mode 100644 index 00000000..a0a38e77 --- /dev/null +++ b/canvas/src/components/canvas/useDragHandlers.ts @@ -0,0 +1,284 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { + useReactFlow, + type Node, + type OnNodeDrag, +} from "@xyflow/react"; +import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; +import { clampChildIntoParent, shouldDetach } from "./dragUtils"; + +type WorkspaceNode = Node; + +export interface PendingNestState { + nodeId: string; + targetId: string | null; + nodeName: string; + targetName: string; +} + +interface DragHandlers { + onNodeDragStart: OnNodeDrag>; + onNodeDrag: OnNodeDrag>; + onNodeDragStop: OnNodeDrag>; + pendingNest: PendingNestState | null; + confirmNest: () => void; + cancelNest: () => void; +} + + + +/** + * Encapsulates every drag gesture on the canvas: + * + * - On drag start, snapshot the modifier keys (Alt / Cmd-Meta) and + * remember which parent the node lived in so we can detect a + * re-parent on release. + * - On drag (mousemove), compute the best drop target via an + * absolute-bounds hit test and publish it via setDragOverNode so + * WorkspaceNode can render the highlight + DropTargetBadge can + * render its label + ghost preview. + * - On drag stop, decide one of: nest into new parent, un-nest, soft + * clamp back inside current parent, or plain move — based on + * modifier keys and hysteresis. Persist the absolute position, + * then run one commit-on-release grow pass on the parent chain. + */ +export function useDragHandlers(): DragHandlers { + const setDragOverNode = useCanvasStore((s) => s.setDragOverNode); + const savePosition = useCanvasStore((s) => s.savePosition); + const nestNode = useCanvasStore((s) => s.nestNode); + const batchNest = useCanvasStore((s) => s.batchNest); + const isDescendant = useCanvasStore((s) => s.isDescendant); + const { getInternalNode } = useReactFlow(); + + const dragModifiersRef = useRef<{ alt: boolean; meta: boolean }>({ + alt: false, + meta: false, + }); + // Remember where the dragged node started so we can put it back on + // cancel. React Flow tracks only the current position during drag; + // if the user drags out → "Extract?" dialog → Cancel, we want the + // card to go back inside its parent at its original coords rather + // than stay dangling at the cancel-time position. + const dragStartStateRef = useRef<{ + nodeId: string; + parentId: string | null; + position: { x: number; y: number }; + } | null>(null); + const [pendingNest, setPendingNest] = useState(null); + + // Absolute-bounds hit test. Tiebreakers in order: highest zIndex + // first (matches what the user sees in front after Cmd+] reorder), + // deepest tree depth second, smallest area third. Depths are + // pre-computed once per call so the whole pass stays O(n). + const findDropTarget = useCallback( + (draggedId: string, point: { x: number; y: number }): string | null => { + const all = useCanvasStore.getState().nodes; + const depthById = new Map(); + for (const n of all) { + depthById.set( + n.id, + n.data.parentId ? (depthById.get(n.data.parentId) ?? 0) + 1 : 0, + ); + } + let best: + | { id: string; depth: number; zIndex: number; area: number } + | null = null; + for (const n of all) { + if (n.id === draggedId || isDescendant(draggedId, n.id)) continue; + const internal = getInternalNode(n.id); + if (!internal) continue; + const abs = internal.internals.positionAbsolute; + const w = internal.measured?.width ?? n.width ?? 220; + const h = internal.measured?.height ?? n.height ?? 120; + if (point.x < abs.x || point.x > abs.x + w) continue; + if (point.y < abs.y || point.y > abs.y + h) continue; + const depth = depthById.get(n.id) ?? 0; + const z = n.zIndex ?? 0; + const area = w * h; + if ( + !best || + z > best.zIndex || + (z === best.zIndex && depth > best.depth) || + (z === best.zIndex && depth === best.depth && area < best.area) + ) { + best = { id: n.id, depth, zIndex: z, area }; + } + } + return best?.id ?? null; + }, + [getInternalNode, isDescendant], + ); + + const onNodeDragStart: OnNodeDrag = useCallback( + (event, node) => { + dragModifiersRef.current = { + alt: event.altKey, + meta: event.metaKey || event.ctrlKey, + }; + dragStartStateRef.current = { + nodeId: node.id, + parentId: node.data.parentId, + position: { x: node.position.x, y: node.position.y }, + }; + }, + [], + ); + + const onNodeDrag: OnNodeDrag = useCallback( + (event, node) => { + dragModifiersRef.current = { + alt: event.altKey, + meta: event.metaKey || event.ctrlKey, + }; + const internal = getInternalNode(node.id); + if (!internal) { + setDragOverNode(null); + return; + } + const abs = internal.internals.positionAbsolute; + const w = internal.measured?.width ?? 220; + const h = internal.measured?.height ?? 120; + const center = { x: abs.x + w / 2, y: abs.y + h / 2 }; + setDragOverNode(findDropTarget(node.id, center)); + }, + [findDropTarget, getInternalNode, setDragOverNode], + ); + + const onNodeDragStop: OnNodeDrag = useCallback( + (event, node) => { + const { dragOverNodeId, nodes: allNodes } = useCanvasStore.getState(); + setDragOverNode(null); + + const nodeName = node.data.name; + const currentParentId = node.data.parentId; + const forceDetach = + event.metaKey || event.ctrlKey || dragModifiersRef.current.meta; + const droppingIntoAnotherParent = + !!dragOverNodeId && dragOverNodeId !== currentParentId; + // Past the 20 %-overlap hysteresis? Treat the gesture as a + // deliberate drag-out. Below that threshold we soft-clamp the + // child back inside so a twitchy release doesn't un-nest + // accidentally (same intent as before, just: plain drag works + // without a modifier now). + const pastHysteresis = + !!currentParentId && + shouldDetach(node.id, currentParentId, getInternalNode); + + if (droppingIntoAnotherParent) { + // Explicit drop onto another workspace always wins over + // clamp/detach — the user pointed at a new target. + const targetNode = allNodes.find((n) => n.id === dragOverNodeId); + const targetName = targetNode?.data.name || "Unknown"; + setPendingNest({ + nodeId: node.id, + targetId: dragOverNodeId, + nodeName, + targetName, + }); + } else if (currentParentId && (forceDetach || pastHysteresis)) { + // Dragged past the edge (or Cmd-held as a force override): the + // user wants out of the parent. Confirm the un-nest. + const parentNode = allNodes.find((n) => n.id === currentParentId); + const parentName = parentNode?.data.name || "Unknown"; + setPendingNest({ + nodeId: node.id, + targetId: null, + nodeName, + targetName: parentName, + }); + } else if (currentParentId) { + // Still inside parent but the drag ended slightly past the + // edge (under 20 % outside). Snap back in so the card doesn't + // visually spill — Miro frame behaviour. + clampChildIntoParent(node.id, currentParentId, getInternalNode); + } + + const internal = getInternalNode(node.id); + const abs = internal?.internals.positionAbsolute ?? node.position; + savePosition(node.id, abs.x, abs.y); + useCanvasStore.getState().growParentsToFitChildren(); + }, + [getInternalNode, savePosition, setDragOverNode], + ); + + const confirmNest = useCallback(() => { + if (!pendingNest) return; + // Close the dialog before dispatching the async store action so a + // second drag can't kick off a competing batch while this one is + // still mid-flight. The store actions surface their own errors via + // showToast, so `void` is the right pattern here. + const pending = pendingNest; + setPendingNest(null); + dragStartStateRef.current = null; + const state = useCanvasStore.getState(); + if ( + state.selectedNodeIds.size > 1 && + state.selectedNodeIds.has(pending.nodeId) + ) { + void batchNest(Array.from(state.selectedNodeIds), pending.targetId); + } else { + void nestNode(pending.nodeId, pending.targetId); + } + }, [pendingNest, nestNode, batchNest]); + + const cancelNest = useCallback(() => { + // Restore the dragged card to wherever it started. Without this, + // a user who drags a child out of a parent then clicks Cancel + // leaves the card stranded outside the parent with no visual + // parent link — a state that doesn't match any save-backed + // truth (the DB position was already written on drag-stop). + const start = dragStartStateRef.current; + if (start) { + const { nodes } = useCanvasStore.getState(); + // Strip the parent's explicit width/height while we're restoring + // the child. `growParentsToFitChildren` ran on drag-stop to fit + // the then-outside child, so without this step the parent stays + // visibly grown even after the child snaps back inside. + // Clearing width/height lets React Flow re-measure from CSS + // min-width/min-height, which collapses to the actual content. + const nextNodes = nodes.map((n) => { + if (n.id === start.nodeId) { + return { ...n, position: start.position }; + } + if (start.parentId && n.id === start.parentId) { + const { width: _w, height: _h, ...rest } = n; + void _w; void _h; + return rest as typeof n; + } + return n; + }); + useCanvasStore.setState({ nodes: nextNodes }); + // Write the restore back to the DB so a reload shows the same + // position. Convert the stored relative position back to absolute + // via the parent's absolute origin before saving. + const parent = start.parentId + ? nodes.find((n) => n.id === start.parentId) + : null; + const parentInternal = start.parentId + ? getInternalNode(start.parentId) + : null; + const parentAbs = parentInternal?.internals.positionAbsolute ?? { + x: parent?.position.x ?? 0, + y: parent?.position.y ?? 0, + }; + savePosition( + start.nodeId, + start.position.x + parentAbs.x, + start.position.y + parentAbs.y, + ); + } + dragStartStateRef.current = null; + setPendingNest(null); + }, [getInternalNode, savePosition]); + + return { + onNodeDragStart, + onNodeDrag, + onNodeDragStop, + pendingNest, + confirmNest, + cancelNest, + }; +} diff --git a/canvas/src/components/canvas/useKeyboardShortcuts.ts b/canvas/src/components/canvas/useKeyboardShortcuts.ts new file mode 100644 index 00000000..f9f67fd8 --- /dev/null +++ b/canvas/src/components/canvas/useKeyboardShortcuts.ts @@ -0,0 +1,87 @@ +"use client"; + +import { useEffect } from "react"; +import { useCanvasStore } from "@/store/canvas"; + +/** + * Canvas-wide keyboard shortcuts. All bound to the document window so + * they work regardless of focused node, except when the user is typing + * into an input (`inInput` short-circuits handling). + * + * Esc — close context menu, clear selection, deselect + * Enter — descend into selected node's first child + * Shift+Enter — ascend to selected node's parent + * Cmd/Ctrl+] — bump selected node forward in z-order + * Cmd/Ctrl+[ — bump selected node backward in z-order + * Z — zoom-to-team if the selected node has children + */ +export function useKeyboardShortcuts() { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement).tagName; + const inInput = + tag === "INPUT" || + tag === "TEXTAREA" || + tag === "SELECT" || + (e.target as HTMLElement).isContentEditable; + + if (e.key === "Escape") { + const state = useCanvasStore.getState(); + if (state.contextMenu) { + state.closeContextMenu(); + } else if (state.selectedNodeIds.size > 0) { + state.clearSelection(); + } else if (state.selectedNodeId) { + state.selectNode(null); + } + } + + // Figma-style hierarchy navigation. Skipped when the user is + // typing so Enter can still submit forms. + if (!inInput && (e.key === "Enter" || e.key === "NumpadEnter")) { + e.preventDefault(); + const state = useCanvasStore.getState(); + const id = state.selectedNodeId; + if (!id) return; + if (e.shiftKey) { + const sel = state.nodes.find((n) => n.id === id); + const parentId = sel?.data.parentId ?? null; + if (parentId) state.selectNode(parentId); + } else { + const firstChild = state.nodes.find((n) => n.data.parentId === id); + if (firstChild) state.selectNode(firstChild.id); + } + } + + if ( + !inInput && + (e.metaKey || e.ctrlKey) && + (e.key === "]" || e.key === "[") + ) { + e.preventDefault(); + const state = useCanvasStore.getState(); + const id = state.selectedNodeId; + if (!id) return; + state.bumpZOrder(id, e.key === "]" ? 1 : -1); + } + + if (!inInput && (e.key === "z" || e.key === "Z")) { + const state = useCanvasStore.getState(); + const selectedId = state.selectedNodeId; + if (!selectedId) return; + const hasChildren = state.nodes.some( + (n) => n.data.parentId === selectedId, + ); + if (hasChildren) { + window.dispatchEvent( + new CustomEvent("molecule:zoom-to-team", { + detail: { nodeId: selectedId }, + }), + ); + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); +} diff --git a/canvas/src/components/tabs/ChannelsTab.tsx b/canvas/src/components/tabs/ChannelsTab.tsx index 7402214b..b7e93ea4 100644 --- a/canvas/src/components/tabs/ChannelsTab.tsx +++ b/canvas/src/components/tabs/ChannelsTab.tsx @@ -60,18 +60,36 @@ export function ChannelsTab({ workspaceId }: Props) { const allowedUsersId = useId(); const load = useCallback(async () => { - try { - const [chRes, adRes] = await Promise.all([ - api.get(`/workspaces/${workspaceId}/channels`), - api.get(`/channels/adapters`), - ]); - setChannels(Array.isArray(chRes) ? chRes : []); - setAdapters(Array.isArray(adRes) ? adRes : []); - } catch { - /* ignore */ - } finally { - setLoading(false); + // Fetch channels and adapters independently so a failure in one + // doesn't blank the other. Previously a single Promise.all + silent + // catch meant ANY request failing left both `channels` and + // `adapters` empty — the user saw a "+ Connect" button with no + // platform options, with no clue why. + const [chResult, adResult] = await Promise.allSettled([ + api.get(`/workspaces/${workspaceId}/channels`), + api.get(`/channels/adapters`), + ]); + const errors: string[] = []; + if (chResult.status === "fulfilled") { + setChannels(Array.isArray(chResult.value) ? chResult.value : []); + } else { + console.warn("ChannelsTab: channels load failed", chResult.reason); + errors.push("connected channels"); } + if (adResult.status === "fulfilled") { + setAdapters(Array.isArray(adResult.value) ? adResult.value : []); + } else { + console.warn("ChannelsTab: adapters load failed", adResult.reason); + errors.push("platforms"); + } + // Surface BOTH failure modes so the user can distinguish + // "no channels configured" from "API unreachable". + if (errors.length > 0) { + setError(`Failed to load ${errors.join(" and ")} — try refreshing`); + } else { + setError(""); + } + setLoading(false); }, [workspaceId]); useEffect(() => { load(); }, [load]); diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index daf6d48f..3762ffdc 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -144,12 +144,28 @@ export function ChatTab({ workspaceId, data }: Props) {
{/* Content — both panels are always in the DOM so aria-controls targets exist. - The inactive panel is hidden via the HTML `hidden` attribute (removed from - display and accessibility tree, but present in the DOM for WCAG 4.1.2). */} -