forked from molecule-ai/molecule-core
Merge pull request #1970 from Molecule-AI/fix/restore-quickstart-plus-hotfixes
fix(canvas): playability pass + UX polish (post #1897)
This commit is contained in:
commit
6745a61ebf
@ -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<string | null>(null);
|
||||
const { getIntersectingNodes } = useReactFlow();
|
||||
|
||||
const onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>> = 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<Node<WorkspaceNodeData>> = 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<Node<WorkspaceNodeData>> = 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<ReturnType<typeof setTimeout>>(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<ReturnType<typeof setTimeout>>(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
|
||||
</a>
|
||||
<main id="canvas-main" className="w-screen h-screen bg-zinc-950">
|
||||
<ReactFlow
|
||||
colorMode="dark"
|
||||
nodes={nodes}
|
||||
edges={allEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onPaneClick={onPaneClick}
|
||||
onMoveEnd={onMoveEnd}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
defaultViewport={defaultViewport}
|
||||
fitView={viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
aria-label="Molecule AI workspace canvas"
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
size={1}
|
||||
color="#27272a"
|
||||
<ReactFlow
|
||||
colorMode="dark"
|
||||
nodes={nodes}
|
||||
edges={allEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onPaneClick={onPaneClick}
|
||||
onMoveEnd={onMoveEnd}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
defaultViewport={defaultViewport}
|
||||
fitView={viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
aria-label="Molecule AI workspace canvas"
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
size={1}
|
||||
color="#27272a"
|
||||
/>
|
||||
<Controls
|
||||
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-zinc-800 [&>button]:!border-zinc-700/50 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-700 [&>button:hover]:!text-zinc-200"
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20"
|
||||
maskColor="rgba(0, 0, 0, 0.7)"
|
||||
nodeColor={(node) => {
|
||||
// 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<string, unknown>)?.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}
|
||||
/>
|
||||
<DropTargetBadge />
|
||||
</ReactFlow>
|
||||
|
||||
{/* Screen-reader live region: announces workspace count on canvas load or change */}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{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`}
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 && <EmptyState />}
|
||||
<A2ATopologyOverlay />
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
<ApprovalBanner />
|
||||
<BundleDropZone />
|
||||
<TemplatePalette />
|
||||
<SidePanel />
|
||||
<ContextMenu />
|
||||
<SearchDialog />
|
||||
<Toaster />
|
||||
<ProvisioningTimeout />
|
||||
{!selectedNodeId && <CreateWorkspaceButton />}
|
||||
<BatchActionBar />
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!pendingNest}
|
||||
title={pendingNest?.targetId ? "Nest Workspace" : "Extract Workspace"}
|
||||
message={
|
||||
pendingNest?.targetId
|
||||
? `Move "${pendingNest.nodeName}" inside "${pendingNest.targetName}"? This changes the org hierarchy — ${pendingNest.nodeName} will become a sub-workspace of ${pendingNest.targetName}.`
|
||||
: `Extract "${pendingNest?.nodeName}" from "${pendingNest?.targetName}"? This moves it to the root level.`
|
||||
}
|
||||
confirmLabel={pendingNest?.targetId ? "Nest" : "Extract"}
|
||||
onConfirm={confirmNest}
|
||||
onCancel={cancelNest}
|
||||
/>
|
||||
<Controls
|
||||
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-zinc-800 [&>button]:!border-zinc-700/50 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-700 [&>button:hover]:!text-zinc-200"
|
||||
showInteractive={false}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!pendingDelete}
|
||||
title={pendingDelete?.hasChildren ? "Delete Workspace and Children" : "Delete Workspace"}
|
||||
message={pendingDelete?.hasChildren
|
||||
? `⚠️ Deleting "${pendingDelete?.name}" will permanently delete all of its child workspaces and their data. This cannot be undone.`
|
||||
: `Permanently delete "${pendingDelete?.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
|
||||
confirmLabel={pendingDelete?.hasChildren ? "Delete All" : "Delete"}
|
||||
confirmVariant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
<MiniMap
|
||||
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20"
|
||||
maskColor="rgba(0, 0, 0, 0.7)"
|
||||
nodeColor={(node) => {
|
||||
const status = (node.data as Record<string, unknown>)?.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}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Screen-reader live region: announces workspace count when canvas loads or changes */}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{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`}
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 && <EmptyState />}
|
||||
<A2ATopologyOverlay />
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
<ApprovalBanner />
|
||||
<BundleDropZone />
|
||||
<TemplatePalette />
|
||||
<SidePanel />
|
||||
<ContextMenu />
|
||||
<SearchDialog />
|
||||
<Toaster />
|
||||
<ProvisioningTimeout />
|
||||
{!selectedNodeId && <CreateWorkspaceButton />}
|
||||
<BatchActionBar />
|
||||
|
||||
{/* Confirmation dialog for structure changes */}
|
||||
<ConfirmDialog
|
||||
open={!!pendingNest}
|
||||
title={pendingNest?.targetId ? "Nest Workspace" : "Extract Workspace"}
|
||||
message={
|
||||
pendingNest?.targetId
|
||||
? `Move "${pendingNest.nodeName}" inside "${pendingNest.targetName}"? This changes the org hierarchy — ${pendingNest.nodeName} will become a sub-workspace of ${pendingNest.targetName}.`
|
||||
: `Extract "${pendingNest?.nodeName}" from "${pendingNest?.targetName}"? This moves it to the root level.`
|
||||
}
|
||||
confirmLabel={pendingNest?.targetId ? "Nest" : "Extract"}
|
||||
onConfirm={confirmNest}
|
||||
onCancel={cancelNest}
|
||||
/>
|
||||
|
||||
{/* Confirmation dialog for workspace delete — driven by store */}
|
||||
<ConfirmDialog
|
||||
open={!!pendingDelete}
|
||||
title={pendingDelete?.hasChildren ? "Delete Workspace and Children" : "Delete Workspace"}
|
||||
message={pendingDelete?.hasChildren
|
||||
? `⚠️ Deleting "${pendingDelete?.name}" will permanently delete all of its child workspaces and their data. This cannot be undone.`
|
||||
: `Permanently delete "${pendingDelete?.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
|
||||
confirmLabel={pendingDelete?.hasChildren ? "Delete All" : "Delete"}
|
||||
confirmVariant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
|
||||
{/* Settings Panel — global secrets management drawer */}
|
||||
<SettingsPanel workspaceId={settingsWorkspaceId} />
|
||||
<DeleteConfirmDialog workspaceId={settingsWorkspaceId} />
|
||||
<SettingsPanel workspaceId={settingsWorkspaceId} />
|
||||
<DeleteConfirmDialog workspaceId={settingsWorkspaceId} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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 }]),
|
||||
|
||||
@ -400,6 +400,11 @@ export function TemplatePalette() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{/* Org templates live INSIDE the scroll container so an
|
||||
* expanded list (15+ entries) is reachable instead of
|
||||
* overflowing the fixed footer below. */}
|
||||
<OrgTemplatesSection />
|
||||
|
||||
{loading && (
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-zinc-500 text-center py-8">
|
||||
<Spinner />
|
||||
@ -467,7 +472,6 @@ export function TemplatePalette() {
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
|
||||
<OrgTemplatesSection />
|
||||
<ImportAgentButton onImported={loadTemplates} />
|
||||
<button
|
||||
onClick={loadTemplates}
|
||||
|
||||
@ -1,31 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useCallback } from "react";
|
||||
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
/** Stable selector: returns children, grandchild flag, and descendant count for a node */
|
||||
function useHierarchyInfo(parentId: string) {
|
||||
const childIds = useCanvasStore(
|
||||
useCallback((s) => s.nodes.filter((n) => n.data.parentId === parentId).map((n) => n.id).join(","), [parentId])
|
||||
/** Descendant count for the "N sub" badge — children are first-class nodes
|
||||
* rendered as full cards inside this one via React Flow's native parentId,
|
||||
* so we don't need to subscribe to the actual child list here. */
|
||||
function useDescendantCount(nodeId: string): number {
|
||||
return useCanvasStore(
|
||||
useCallback((s) => countDescendants(nodeId, s.nodes), [nodeId])
|
||||
);
|
||||
const children = useCanvasStore(
|
||||
useShallow((s) => s.nodes.filter((n) => n.data.parentId === parentId))
|
||||
}
|
||||
|
||||
function useHasChildren(nodeId: string): boolean {
|
||||
return useCanvasStore(
|
||||
useCallback((s) => s.nodes.some((n) => n.data.parentId === nodeId), [nodeId])
|
||||
);
|
||||
const hasGrandchildren = useCanvasStore(
|
||||
useCallback((s) => {
|
||||
const ids = childIds.split(",").filter(Boolean);
|
||||
return ids.length > 0 && ids.some((cid) => s.nodes.some((n) => n.data.parentId === cid));
|
||||
}, [childIds])
|
||||
);
|
||||
const descendantCount = useCanvasStore(
|
||||
useCallback((s) => countDescendants(parentId, s.nodes), [parentId])
|
||||
);
|
||||
return { children, hasGrandchildren, descendantCount };
|
||||
}
|
||||
|
||||
/** Eject/extract arrow icon — visually distinct from delete ✕ */
|
||||
@ -52,18 +46,26 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
const toggleNodeSelection = useCanvasStore((s) => s.toggleNodeSelection);
|
||||
const isOnline = data.status === "online";
|
||||
|
||||
// Get children + hierarchy info (single stable selector avoids redundant re-renders)
|
||||
const { children, hasGrandchildren, descendantCount } = useHierarchyInfo(id);
|
||||
const hasChildren = children.length > 0;
|
||||
// Children are first-class RF nodes now (rendered inside this one via
|
||||
// React Flow's native parentId). We only need the count for the badge
|
||||
// and a boolean so parent cards default to a larger size.
|
||||
const hasChildren = useHasChildren(id);
|
||||
const descendantCount = useDescendantCount(id);
|
||||
|
||||
const skills = getSkillNames(data.agentCard);
|
||||
|
||||
const handleExtract = useCallback(
|
||||
(childId: string) => nestNode(childId, null),
|
||||
[nestNode]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* NodeResizer — visible only on the selected card. Lets the user
|
||||
* drag any edge/corner to grow or shrink the workspace, which is
|
||||
* useful on cards that contain nested child workspaces. */}
|
||||
<NodeResizer
|
||||
isVisible={isSelected}
|
||||
minWidth={hasChildren ? 360 : 210}
|
||||
minHeight={hasChildren ? 200 : 110}
|
||||
lineClassName="!border-blue-500/40"
|
||||
handleClassName="!w-2 !h-2 !bg-blue-500 !border !border-blue-300"
|
||||
/>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@ -79,9 +81,23 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (hasChildren) {
|
||||
window.dispatchEvent(new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: id } }));
|
||||
if (!hasChildren) return;
|
||||
// A collapsed parent double-click EXPANDS first (flipping the
|
||||
// collapsed flag + persisting it via the API). Once expanded,
|
||||
// subsequent double-clicks zoom-to-team so the user can see
|
||||
// the hierarchy fit in the viewport. Matches the user's ask:
|
||||
// default-collapsed for clean first paint, one gesture reveals
|
||||
// the subtree.
|
||||
if (data.collapsed) {
|
||||
const state = useCanvasStore.getState();
|
||||
state.setCollapsed(id, false);
|
||||
// Fire-and-forget persist so reload retains the expansion.
|
||||
import("@/lib/api").then(({ api }) => {
|
||||
api.patch(`/workspaces/${id}`, { collapsed: false }).catch(() => {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: id } }));
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
@ -108,8 +124,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
group relative rounded-xl
|
||||
${hasGrandchildren ? "min-w-[720px] max-w-[960px]" : hasChildren ? "min-w-[320px] max-w-[450px]" : "min-w-[210px] max-w-[280px]"}
|
||||
group relative rounded-xl h-full w-full
|
||||
${hasChildren && !data.collapsed ? "min-w-[360px] min-h-[200px]" : "min-w-[210px]"}
|
||||
cursor-pointer overflow-hidden
|
||||
transition-all duration-200 ease-out
|
||||
${isDragTarget
|
||||
@ -214,10 +230,9 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Embedded children — rendered INSIDE the parent node */}
|
||||
{hasChildren && (
|
||||
<EmbeddedTeam members={children} depth={0} onSelect={selectNode} onExtract={handleExtract} />
|
||||
)}
|
||||
{/* Children render as first-class React Flow nodes inside this
|
||||
* card (parentId binding). No embedded TEAM MEMBERS list here —
|
||||
* just keep visual breathing room via the min-height above. */}
|
||||
|
||||
{/* Current task */}
|
||||
{data.currentTask && (
|
||||
@ -283,11 +298,10 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
className="!w-2.5 !h-1 !rounded-full !bg-zinc-600/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_NESTING_DEPTH = 3;
|
||||
|
||||
/** Count all descendants (children + grandchildren + ...) */
|
||||
function countDescendants(nodeId: string, allNodes: Node<WorkspaceNodeData>[], visited = new Set<string>()): number {
|
||||
if (visited.has(nodeId)) return 0;
|
||||
@ -300,192 +314,6 @@ function countDescendants(nodeId: string, allNodes: Node<WorkspaceNodeData>[], v
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Subscribes to allNodes only when children exist — isolates re-renders from parent */
|
||||
function EmbeddedTeam({ members, depth, onSelect, onExtract }: {
|
||||
members: Node<WorkspaceNodeData>[];
|
||||
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 (
|
||||
<div className="mt-2 pt-2 border-t border-zinc-700/30">
|
||||
<div className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1.5">Team Members</div>
|
||||
<div className={useGrid
|
||||
? "grid grid-cols-2 gap-1.5 lg:grid-cols-3"
|
||||
: "space-y-1.5"
|
||||
}>
|
||||
{members.map((child) => (
|
||||
<TeamMemberChip key={child.id} node={child} allNodes={allNodes} depth={depth} onSelect={onSelect} onExtract={onExtract} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Recursive mini-card — mirrors parent card layout at smaller scale */
|
||||
function TeamMemberChip({
|
||||
node,
|
||||
allNodes,
|
||||
depth,
|
||||
onSelect,
|
||||
onExtract,
|
||||
}: {
|
||||
node: Node<WorkspaceNodeData>;
|
||||
allNodes: Node<WorkspaceNodeData>[];
|
||||
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 (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${data.name}`}
|
||||
className="group/child relative rounded-lg bg-zinc-800/60 hover:bg-zinc-700/70 border border-zinc-700/30 hover:border-zinc-600/40 overflow-hidden transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
onClick={(e) => {
|
||||
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 */}
|
||||
<div className={`absolute inset-x-0 top-0 h-5 bg-gradient-to-b ${statusCfg.bar} pointer-events-none`} />
|
||||
|
||||
<div className="relative px-2 py-1.5">
|
||||
{/* Header: name + badges + extract */}
|
||||
<div className="flex items-center justify-between gap-1 mb-0.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusCfg.dot}`} />
|
||||
<span className="text-[10px] font-semibold text-zinc-200 truncate leading-tight">
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{hasSubChildren && (
|
||||
<span className="text-[7px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1 py-0.5 rounded">
|
||||
{descendantCount}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[7px] font-mono px-1 py-0.5 rounded ${tierCfg.color}`}>
|
||||
{tierCfg.label}
|
||||
</span>
|
||||
<button
|
||||
aria-label={`Extract ${data.name} from team`}
|
||||
title={`Extract ${data.name} from team`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExtract(node.id);
|
||||
}}
|
||||
className="opacity-0 group-hover/child:opacity-100 text-zinc-500 hover:text-sky-400 transition-all focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:outline-none rounded"
|
||||
>
|
||||
<EjectIcon aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
{data.role && (
|
||||
<div className="text-[10px] text-zinc-500 mb-1 leading-tight truncate">{data.role}</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-0.5 mb-1">
|
||||
{skills.slice(0, 3).map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className={`text-[10px] px-1 py-0.5 rounded border ${
|
||||
isOnline
|
||||
? "text-emerald-300/70 bg-emerald-950/20 border-emerald-800/20"
|
||||
: "text-zinc-500 bg-zinc-800/40 border-zinc-700/30"
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{skills.length > 3 && (
|
||||
<span className="text-[10px] text-zinc-400 self-center">+{skills.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status + active tasks row */}
|
||||
<div className="flex items-center justify-between">
|
||||
{data.status !== "online" ? (
|
||||
<span className={`text-[10px] uppercase tracking-widest font-medium ${
|
||||
data.status === "failed" ? "text-red-400" :
|
||||
data.status === "degraded" ? "text-amber-300" :
|
||||
data.status === "provisioning" ? "text-sky-400" :
|
||||
"text-zinc-500"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
) : <div />}
|
||||
{data.activeTasks > 0 && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
|
||||
<span className="text-[10px] text-amber-300 tabular-nums">
|
||||
{data.activeTasks}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current task banner for sub-agents */}
|
||||
{data.currentTask && (
|
||||
<Tooltip text={String(data.currentTask)}>
|
||||
<div className="flex items-center gap-1 mt-0.5 px-1.5 py-0.5 bg-amber-950/20 rounded border border-amber-800/20 cursor-default">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
||||
<span className="text-[10px] text-amber-300 truncate">{data.currentTask}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Recursive sub-children rendered inside this card */}
|
||||
{hasSubChildren && depth < MAX_NESTING_DEPTH && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-zinc-700/20">
|
||||
<div className="text-[10px] text-zinc-400 uppercase tracking-widest mb-1">Team</div>
|
||||
<div className={subChildren.length >= 2 ? "grid grid-cols-2 gap-1" : "space-y-1"}>
|
||||
{subChildren.map((sub) => (
|
||||
<TeamMemberChip key={sub.id} node={sub} allNodes={allNodes} depth={depth + 1} onSelect={onSelect} onExtract={onExtract} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSkillNames(agentCard: Record<string, unknown> | null): string[] {
|
||||
if (!agentCard) return [];
|
||||
|
||||
@ -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 <name>" 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<string>(),
|
||||
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(<WorkspaceNode id={PARENT_ID} data={PARENT_DATA as any} />);
|
||||
}
|
||||
|
||||
// ── 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 <name>'", () => {
|
||||
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 <name> from team'", () => {
|
||||
renderParentNode();
|
||||
const ejectBtn = screen.getByRole("button", {
|
||||
name: "Extract Child Workspace from team",
|
||||
});
|
||||
expect(ejectBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -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> = {}): 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<WorkspaceNodeData>[] = [
|
||||
{ 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<string>(),
|
||||
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(
|
||||
<WorkspaceNode
|
||||
id={PARENT_ID}
|
||||
data={parentNodeData}
|
||||
// NodeProps — all required fields included; React Flow internals unused in mock env
|
||||
type="workspaceNode"
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
dragging={false}
|
||||
draggable={false}
|
||||
selectable={false}
|
||||
deletable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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");
|
||||
});
|
||||
});
|
||||
@ -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"');
|
||||
|
||||
83
canvas/src/components/canvas/DropTargetBadge.tsx
Normal file
83
canvas/src/components/canvas/DropTargetBadge.tsx
Normal file
@ -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: <name>" 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 && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-40 rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-500/10"
|
||||
style={{
|
||||
left: slotTL.x,
|
||||
top: slotTL.y,
|
||||
width: slotBR.x - slotTL.x,
|
||||
height: slotBR.y - slotTL.y,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-emerald-50 shadow-lg shadow-emerald-950/40"
|
||||
style={{ left: badge.x, top: badge.y - 6 }}
|
||||
>
|
||||
Drop into: {targetName}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
canvas/src/components/canvas/dragUtils.ts
Normal file
74
canvas/src/components/canvas/dragUtils.ts
Normal file
@ -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<ReturnType<typeof useReactFlow>["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,
|
||||
),
|
||||
});
|
||||
}
|
||||
96
canvas/src/components/canvas/useCanvasViewport.ts
Normal file
96
canvas/src/components/canvas/useCanvasViewport.ts
Normal file
@ -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<ReturnType<typeof setTimeout>>(undefined);
|
||||
const panTimerRef = useRef<ReturnType<typeof setTimeout>>(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 };
|
||||
}
|
||||
284
canvas/src/components/canvas/useDragHandlers.ts
Normal file
284
canvas/src/components/canvas/useDragHandlers.ts
Normal file
@ -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<WorkspaceNodeData>;
|
||||
|
||||
export interface PendingNestState {
|
||||
nodeId: string;
|
||||
targetId: string | null;
|
||||
nodeName: string;
|
||||
targetName: string;
|
||||
}
|
||||
|
||||
interface DragHandlers {
|
||||
onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>>;
|
||||
onNodeDrag: OnNodeDrag<Node<WorkspaceNodeData>>;
|
||||
onNodeDragStop: OnNodeDrag<Node<WorkspaceNodeData>>;
|
||||
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<PendingNestState | null>(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<string, number>();
|
||||
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<WorkspaceNode> = 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<WorkspaceNode> = 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<WorkspaceNode> = 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,
|
||||
};
|
||||
}
|
||||
87
canvas/src/components/canvas/useKeyboardShortcuts.ts
Normal file
87
canvas/src/components/canvas/useKeyboardShortcuts.ts
Normal file
@ -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);
|
||||
}, []);
|
||||
}
|
||||
@ -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<Channel[]>(`/workspaces/${workspaceId}/channels`),
|
||||
api.get<ChannelAdapter[]>(`/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<Channel[]>(`/workspaces/${workspaceId}/channels`),
|
||||
api.get<ChannelAdapter[]>(`/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]);
|
||||
|
||||
@ -144,12 +144,28 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
{/* 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). */}
|
||||
<div id="chat-panel-my-chat" role="tabpanel" aria-labelledby="chat-tab-my-chat" hidden={subTab !== "my-chat"} className="flex-1 overflow-hidden flex flex-col">
|
||||
Inactive panel is hidden via a conditional `hidden` Tailwind class
|
||||
(display: none) because the native HTML `hidden` attribute is
|
||||
overridden by the panel's own `flex` utility — that's why both
|
||||
sections used to render stacked. */}
|
||||
<div
|
||||
id="chat-panel-my-chat"
|
||||
role="tabpanel"
|
||||
aria-labelledby="chat-tab-my-chat"
|
||||
className={`flex-1 overflow-hidden flex-col ${
|
||||
subTab === "my-chat" ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<MyChatPanel workspaceId={workspaceId} data={data} />
|
||||
</div>
|
||||
<div id="chat-panel-agent-comms" role="tabpanel" aria-labelledby="chat-tab-agent-comms" hidden={subTab !== "agent-comms"} className="flex-1 overflow-hidden flex flex-col">
|
||||
<div
|
||||
id="chat-panel-agent-comms"
|
||||
role="tabpanel"
|
||||
aria-labelledby="chat-tab-agent-comms"
|
||||
className={`flex-1 overflow-hidden flex-col ${
|
||||
subTab === "agent-comms" ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<AgentCommsPanel workspaceId={workspaceId} />
|
||||
</div>
|
||||
</div>
|
||||
@ -200,7 +216,12 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Consume agent push messages (send_message_to_user) from global store
|
||||
// Consume agent push messages (send_message_to_user) from global store.
|
||||
// Runtimes like Claude Code SDK deliver their reply via a WS push rather
|
||||
// than the /a2a HTTP response — when that happens, the push is the
|
||||
// authoritative "reply arrived" signal for the UI, so clear `sending`
|
||||
// here too. The HTTP .then() coordinates through sendingFromAPIRef so
|
||||
// whichever path clears first wins.
|
||||
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
|
||||
useEffect(() => {
|
||||
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
|
||||
@ -213,23 +234,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
// push for the same content).
|
||||
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content)));
|
||||
}
|
||||
}, [pendingAgentMsgs, workspaceId]);
|
||||
|
||||
// Consume A2A_RESPONSE events from global store (streaming response delivery).
|
||||
// Guarded by sendingFromAPIRef to avoid duplicate messages when the
|
||||
// synchronous HTTP .then() handler also fires for the same response.
|
||||
const pendingA2AResponse = useCanvasStore((s) => s.agentMessages[`a2a:${workspaceId}`]);
|
||||
useEffect(() => {
|
||||
if (!pendingA2AResponse || pendingA2AResponse.length === 0) return;
|
||||
const consume = useCanvasStore.getState().consumeAgentMessages;
|
||||
const msgs = consume(`a2a:${workspaceId}`);
|
||||
if (!sendingFromAPIRef.current) return; // HTTP .then() already handled this response
|
||||
for (const m of msgs) {
|
||||
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content)));
|
||||
if (sendingFromAPIRef.current && msgs.length > 0) {
|
||||
setSending(false);
|
||||
sendingFromAPIRef.current = false;
|
||||
}
|
||||
setSending(false);
|
||||
sendingFromAPIRef.current = false;
|
||||
}, [pendingA2AResponse, workspaceId]);
|
||||
}, [pendingAgentMsgs, workspaceId]);
|
||||
|
||||
// Resolve workspace ID → name for activity display
|
||||
const resolveWorkspaceName = useCallback((id: string) => {
|
||||
@ -281,8 +290,24 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
if (status === "ok" && durationMs) {
|
||||
const sec = Math.round(durationMs / 1000);
|
||||
line = `← ${targetName} responded (${sec}s)`;
|
||||
// The platform logs a successful a2a_receive once the workspace
|
||||
// has fully produced its reply. That's the authoritative "done"
|
||||
// signal for the spinner — clear it even if the reply hasn't
|
||||
// surfaced through the store yet (it may be delivered shortly
|
||||
// via pendingAgentMsgs or the HTTP .then()).
|
||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||
if (own && sendingFromAPIRef.current) {
|
||||
setSending(false);
|
||||
sendingFromAPIRef.current = false;
|
||||
}
|
||||
} else if (status === "error") {
|
||||
line = `⚠ ${targetName} error`;
|
||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||
if (own && sendingFromAPIRef.current) {
|
||||
setSending(false);
|
||||
sendingFromAPIRef.current = false;
|
||||
setError("Agent error (Exception) — see workspace logs for details.");
|
||||
}
|
||||
}
|
||||
} else if (type === "a2a_send") {
|
||||
const targetName = resolveWorkspaceName(targetId);
|
||||
@ -301,7 +326,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
setActivityLog((prev) => [...prev.slice(-8), `⟳ ${task}`]);
|
||||
}
|
||||
}
|
||||
// A2A_RESPONSE is handled by the store (pendingA2AResponse effect) — no duplicate here
|
||||
// A2A_RESPONSE is already consumed by the store and its text is
|
||||
// appended to messages via the pendingAgentMsgs effect above; we
|
||||
// don't need to duplicate it here.
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
|
||||
@ -241,15 +241,65 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
setSuccess(false);
|
||||
try {
|
||||
const content = rawMode ? rawDraft : toYaml(config);
|
||||
await api.put(`/workspaces/${workspaceId}/files/config.yaml`, { content });
|
||||
const runtimeManagesOwnConfig = RUNTIMES_WITH_OWN_CONFIG.has(config.runtime || "");
|
||||
// Only write the platform-managed config.yaml when the runtime
|
||||
// actually consumes it. Hermes + external runtimes manage their
|
||||
// own config file inside the container, so writing this one is a
|
||||
// no-op at best and can fail with 404 if config.yaml was never
|
||||
// created for this workspace.
|
||||
if (!runtimeManagesOwnConfig) {
|
||||
await api.put(`/workspaces/${workspaceId}/files/config.yaml`, { content });
|
||||
}
|
||||
|
||||
// If runtime changed, update it in the DB so restart uses the correct image
|
||||
const newRuntime = rawMode
|
||||
? (parseYaml(rawDraft).runtime as string || "")
|
||||
: (config.runtime || "");
|
||||
const oldRuntime = (parseYaml(originalYaml).runtime as string || "");
|
||||
if (newRuntime && newRuntime !== oldRuntime) {
|
||||
await api.patch(`/workspaces/${workspaceId}`, { runtime: newRuntime });
|
||||
// DB-backed fields (name, tier, runtime, model) live on the
|
||||
// workspace row, NOT in config.yaml. Fire separate PATCHes for
|
||||
// the ones that actually changed — otherwise a Hermes user edits
|
||||
// the form, hits Save, sees the request succeed, then watches the
|
||||
// values snap back on the next reload because the workspace row
|
||||
// never heard about the change.
|
||||
//
|
||||
// Diff against the RAW parsed YAML (or the form `config` in non-
|
||||
// raw mode) rather than the DEFAULT_CONFIG-merged shape — if the
|
||||
// user deleted a field in raw mode the merge would substitute the
|
||||
// default (e.g. tier=1) and we'd silently PATCH that down from
|
||||
// the stored value. Only fields the user actually typed get sent.
|
||||
const oldParsed = parseYaml(originalYaml);
|
||||
const nextSource = rawMode
|
||||
? (parseYaml(rawDraft) as Record<string, unknown>)
|
||||
: (config as unknown as Record<string, unknown>);
|
||||
const dbPatch: Record<string, unknown> = {};
|
||||
if (typeof nextSource.name === "string" && nextSource.name && nextSource.name !== oldParsed.name) {
|
||||
dbPatch.name = nextSource.name;
|
||||
}
|
||||
if (typeof nextSource.tier === "number" && nextSource.tier !== (oldParsed.tier ?? null)) {
|
||||
dbPatch.tier = nextSource.tier;
|
||||
}
|
||||
const oldRuntime = (oldParsed.runtime as string) || "";
|
||||
if (typeof nextSource.runtime === "string" && nextSource.runtime && nextSource.runtime !== oldRuntime) {
|
||||
dbPatch.runtime = nextSource.runtime;
|
||||
}
|
||||
if (Object.keys(dbPatch).length > 0) {
|
||||
await api.patch(`/workspaces/${workspaceId}`, dbPatch);
|
||||
}
|
||||
|
||||
// Model has its own endpoint (separate from the general workspace
|
||||
// PATCH) because the runtime may need to validate it against the
|
||||
// template's supported models list. A model rejection is a
|
||||
// partial-save state — we report it as a user-visible warning
|
||||
// rather than lying "Saved" and letting the user discover the
|
||||
// revert on next reload.
|
||||
const oldModel = (oldParsed.model as string) || "";
|
||||
let modelSaveError: string | null = null;
|
||||
if (
|
||||
typeof nextSource.model === "string" &&
|
||||
nextSource.model &&
|
||||
nextSource.model !== oldModel
|
||||
) {
|
||||
try {
|
||||
await api.put(`/workspaces/${workspaceId}/model`, { model: nextSource.model });
|
||||
} catch (e) {
|
||||
modelSaveError = e instanceof Error ? e.message : "Model update was rejected";
|
||||
}
|
||||
}
|
||||
|
||||
setOriginalYaml(content);
|
||||
@ -264,9 +314,16 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
} else {
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
|
||||
}
|
||||
setSuccess(true);
|
||||
clearTimeout(successTimerRef.current);
|
||||
successTimerRef.current = setTimeout(() => setSuccess(false), 2000);
|
||||
if (modelSaveError) {
|
||||
// Partial-save UX: surface the model rejection instead of
|
||||
// showing "Saved" — the user would otherwise watch the model
|
||||
// field revert on next reload with no explanation.
|
||||
setError(`Other fields saved, but model update failed: ${modelSaveError}`);
|
||||
} else {
|
||||
setSuccess(true);
|
||||
clearTimeout(successTimerRef.current);
|
||||
successTimerRef.current = setTimeout(() => setSuccess(false), 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
|
||||
@ -68,22 +68,32 @@ export function SkillsTab({ data }: Props) {
|
||||
const loadInstalled = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.get<PluginInfo[]>(`/workspaces/${workspaceId}/plugins`);
|
||||
if (mountedRef.current) setInstalled(result);
|
||||
} catch { /* ignore */ }
|
||||
if (mountedRef.current) setInstalled(Array.isArray(result) ? result : []);
|
||||
} catch (e) {
|
||||
console.warn("SkillsTab: installed plugins load failed", e);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const loadRegistry = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.get<PluginInfo[]>("/plugins");
|
||||
if (mountedRef.current) setRegistry(result);
|
||||
} catch { /* ignore */ }
|
||||
if (mountedRef.current) setRegistry(Array.isArray(result) ? result : []);
|
||||
} catch (e) {
|
||||
// Registry is the AVAILABLE PLUGINS list. Silent failure here
|
||||
// left the user seeing "No plugins in registry" with no clue
|
||||
// it was a fetch error — log it so devtools shows the cause.
|
||||
console.warn("SkillsTab: registry load failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSourceSchemes = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.get<SourceSchemesResponse>("/plugins/sources");
|
||||
if (mountedRef.current) setSourceSchemes(result.schemes ?? []);
|
||||
} catch { /* ignore — falls back to "local only" UX */ }
|
||||
} catch (e) {
|
||||
console.warn("SkillsTab: plugin sources load failed", e);
|
||||
// Falls back to "local only" UX — non-fatal.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -17,7 +17,8 @@ const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
body?: unknown,
|
||||
retryCount = 0,
|
||||
): Promise<T> {
|
||||
// SaaS cross-origin shape:
|
||||
// - X-Molecule-Org-Slug: derived from window.location.hostname by
|
||||
@ -38,6 +39,18 @@ async function request<T>(
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
// Transient rate-limit recovery. A single IP bucket can momentarily
|
||||
// spike on page load (several panels hydrate simultaneously). Instead
|
||||
// of bubbling up a 429 that blanks the Canvas, wait the
|
||||
// Retry-After window and try once — any further 429 surfaces normally.
|
||||
// GET / idempotent methods only; never auto-retry mutations.
|
||||
if (res.status === 429 && retryCount === 0 && method === "GET") {
|
||||
const retryAfterHeader = res.headers.get("Retry-After");
|
||||
const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN;
|
||||
const delayMs = Number.isFinite(retryAfter) ? Math.min(retryAfter, 20) * 1000 : 2000;
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
return request<T>(method, path, body, retryCount + 1);
|
||||
}
|
||||
if (res.status === 401) {
|
||||
// Session expired or credentials lost. On SaaS (tenant subdomain)
|
||||
// the login page lives at /cp/auth/login and is mounted by the
|
||||
|
||||
@ -361,7 +361,7 @@ describe("handleCanvasEvent – WORKSPACE_REMOVED", () => {
|
||||
const { nodes: updatedNodes } = set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] };
|
||||
const updatedChild = updatedNodes.find((n) => n.id === "child")!;
|
||||
expect(updatedChild.data.parentId).toBe("parent");
|
||||
expect(updatedChild.hidden).toBe(true); // still has a parent
|
||||
expect(updatedChild.parentId).toBe("parent"); // RF binding re-pointed
|
||||
});
|
||||
|
||||
it("reparents children to null when root node is removed", () => {
|
||||
@ -374,7 +374,7 @@ describe("handleCanvasEvent – WORKSPACE_REMOVED", () => {
|
||||
const { nodes: updatedNodes } = set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] };
|
||||
const updatedChild = updatedNodes.find((n) => n.id === "child")!;
|
||||
expect(updatedChild.data.parentId).toBeNull();
|
||||
expect(updatedChild.hidden).toBe(false);
|
||||
expect(updatedChild.parentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes edges connected to the removed workspace", () => {
|
||||
|
||||
@ -110,7 +110,10 @@ describe("buildNodesAndEdges – parent + child workspaces", () => {
|
||||
expect(edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("marks parent as visible and child as hidden", () => {
|
||||
it("binds child to parent via React Flow's native parentId", () => {
|
||||
// Children are first-class nodes now (rendered as full cards inside
|
||||
// their parent via RF's parentId). No `hidden` flag anymore — the
|
||||
// nesting is visual, not hide-and-show.
|
||||
const { nodes } = buildNodesAndEdges([
|
||||
makeWS({ id: "parent" }),
|
||||
makeWS({ id: "child", parent_id: "parent" }),
|
||||
@ -120,7 +123,9 @@ describe("buildNodesAndEdges – parent + child workspaces", () => {
|
||||
const child = nodes.find((n) => n.id === "child")!;
|
||||
|
||||
expect(parent.hidden).toBeFalsy();
|
||||
expect(child.hidden).toBe(true);
|
||||
expect(child.hidden).toBeFalsy();
|
||||
expect(parent.parentId).toBeUndefined();
|
||||
expect(child.parentId).toBe("parent");
|
||||
});
|
||||
|
||||
it("stores parent_id in child node data as parentId", () => {
|
||||
@ -157,9 +162,9 @@ describe("buildNodesAndEdges – deeply nested hierarchy", () => {
|
||||
expect(nodes).toHaveLength(3);
|
||||
expect(edges).toHaveLength(0);
|
||||
|
||||
expect(nodes.find((n) => n.id === "root")!.hidden).toBeFalsy();
|
||||
expect(nodes.find((n) => n.id === "mid")!.hidden).toBe(true);
|
||||
expect(nodes.find((n) => n.id === "leaf")!.hidden).toBe(true);
|
||||
expect(nodes.find((n) => n.id === "root")!.parentId).toBeUndefined();
|
||||
expect(nodes.find((n) => n.id === "mid")!.parentId).toBe("root");
|
||||
expect(nodes.find((n) => n.id === "leaf")!.parentId).toBe("mid");
|
||||
|
||||
expect(nodes.find((n) => n.id === "mid")!.data.parentId).toBe("root");
|
||||
expect(nodes.find((n) => n.id === "leaf")!.data.parentId).toBe("mid");
|
||||
@ -175,9 +180,9 @@ describe("buildNodesAndEdges – deeply nested hierarchy", () => {
|
||||
const { nodes } = buildNodesAndEdges(workspaces);
|
||||
|
||||
expect(nodes).toHaveLength(3);
|
||||
expect(nodes.find((n) => n.id === "root-a")!.hidden).toBeFalsy();
|
||||
expect(nodes.find((n) => n.id === "root-b")!.hidden).toBeFalsy();
|
||||
expect(nodes.find((n) => n.id === "child-a")!.hidden).toBe(true);
|
||||
expect(nodes.find((n) => n.id === "root-a")!.parentId).toBeUndefined();
|
||||
expect(nodes.find((n) => n.id === "root-b")!.parentId).toBeUndefined();
|
||||
expect(nodes.find((n) => n.id === "child-a")!.parentId).toBe("root-a");
|
||||
});
|
||||
});
|
||||
|
||||
@ -358,3 +363,58 @@ describe("buildNodesAndEdges – layoutOverrides applied", () => {
|
||||
expect(nodes[0].position).toEqual({ x: 100, y: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Rescue heuristic for out-of-bounds children ----------
|
||||
//
|
||||
// Parent starts at min size for its child count (2-col grid). For a
|
||||
// parent with one child, parentMinSize(1) is ~300 × 200. Each of the
|
||||
// tests below fixes the parent origin at (1000, 500) so the test
|
||||
// cases read cleanly.
|
||||
|
||||
describe("buildNodesAndEdges – child rescue heuristic", () => {
|
||||
const PARENT_ABS = { x: 1000, y: 500 };
|
||||
|
||||
function scenario(childAbs: { x: number; y: number }) {
|
||||
return buildNodesAndEdges([
|
||||
makeWS({ id: "p", name: "Parent", x: PARENT_ABS.x, y: PARENT_ABS.y }),
|
||||
makeWS({ id: "c", name: "Child", parent_id: "p", x: childAbs.x, y: childAbs.y }),
|
||||
]).nodes.find((n) => n.id === "c")!;
|
||||
}
|
||||
|
||||
it("rescues a child whose bbox falls entirely outside the parent (screenshot case)", () => {
|
||||
// Child abs (580, 795) with parent at (1000, 500) → rel (-420, 295)
|
||||
// The child's right edge sits at -160, entirely left of parent.
|
||||
// Expect the grid slot, not the negative stored position.
|
||||
const child = scenario({ x: 580, y: 795 });
|
||||
expect(child.position.x).toBeGreaterThanOrEqual(0);
|
||||
expect(child.position.y).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("keeps a child whose stored position drifts slightly negative (user moved parent past child)", () => {
|
||||
// Child abs (960, 460), parent (1000, 500) → rel (-40, -40).
|
||||
// Child right/bottom edges still overlap the parent bbox; this is
|
||||
// a recoverable layout, not corruption. Leave it alone.
|
||||
const child = scenario({ x: 960, y: 460 });
|
||||
expect(child.position).toEqual({ x: -40, y: -40 });
|
||||
});
|
||||
|
||||
it("rescues a child stored with legacy huge-positive coords", () => {
|
||||
// Abs (50000, 50000) with parent at (1000, 500) → rel (49000, 49500).
|
||||
// No overlap possible with any reasonable parent size — rescue.
|
||||
const child = scenario({ x: 50000, y: 50000 });
|
||||
expect(child.position.x).toBeLessThan(1000);
|
||||
expect(child.position.y).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("keeps a child placed inside a user-resized parent past the initial min size", () => {
|
||||
// parentMinSize(1) is ~300×200. A child placed at rel (450, 300)
|
||||
// would be past the initial min bounds but INSIDE a user-grown
|
||||
// parent of, say, 600×400. We can't know the user's resized size
|
||||
// from topology alone — but the child's bbox still overlaps the
|
||||
// initial parent bbox on at least the X axis because its top-left
|
||||
// is only 450px in (less than the computed parent width for most
|
||||
// child counts). Verify the intermediate case is preserved.
|
||||
const child = scenario({ x: PARENT_ABS.x + 100, y: PARENT_ABS.y + 50 });
|
||||
expect(child.position).toEqual({ x: 100, y: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -92,7 +92,11 @@ describe("hydrate", () => {
|
||||
expect(edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sets hidden=true for nodes with parent_id", () => {
|
||||
it("binds children to their parent via React Flow parentId", () => {
|
||||
// The old model hid child nodes + embedded them as chips inside the
|
||||
// parent card. The new model renders every workspace as a first-class
|
||||
// card, using React Flow's native parentId to group them so moving
|
||||
// the parent carries the children along.
|
||||
const workspaces = [
|
||||
makeWS({ id: "parent", name: "Parent" }),
|
||||
makeWS({ id: "child", name: "Child", parent_id: "parent" }),
|
||||
@ -105,7 +109,9 @@ describe("hydrate", () => {
|
||||
const child = nodes.find((n) => n.id === "child")!;
|
||||
|
||||
expect(parent.hidden).toBeFalsy();
|
||||
expect(child.hidden).toBe(true);
|
||||
expect(child.hidden).toBeFalsy();
|
||||
expect(parent.parentId).toBeUndefined();
|
||||
expect(child.parentId).toBe("parent");
|
||||
expect(child.data.parentId).toBe("parent");
|
||||
});
|
||||
|
||||
@ -331,7 +337,7 @@ describe("applyEvent", () => {
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].id).toBe("ws-2");
|
||||
expect(nodes[0].data.parentId).toBeNull();
|
||||
expect(nodes[0].hidden).toBe(false);
|
||||
expect(nodes[0].parentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("WORKSPACE_REMOVED clears selectedNodeId if removed", () => {
|
||||
@ -454,7 +460,7 @@ describe("removeNode", () => {
|
||||
|
||||
const leaf = useCanvasStore.getState().nodes.find((n) => n.id === "leaf")!;
|
||||
expect(leaf.data.parentId).toBe("root");
|
||||
expect(leaf.hidden).toBe(true); // still has a parent
|
||||
expect(leaf.parentId).toBe("root"); // RF binding also re-pointed
|
||||
});
|
||||
|
||||
it("reparents children to null when root is deleted", () => {
|
||||
@ -462,7 +468,7 @@ describe("removeNode", () => {
|
||||
|
||||
const mid = useCanvasStore.getState().nodes.find((n) => n.id === "mid")!;
|
||||
expect(mid.data.parentId).toBeNull();
|
||||
expect(mid.hidden).toBe(false);
|
||||
expect(mid.parentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears selection if removed node was selected", () => {
|
||||
@ -655,23 +661,21 @@ describe("nestNode", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("optimistically updates parentId and hidden", async () => {
|
||||
it("optimistically updates parentId and the RF parent binding", async () => {
|
||||
await useCanvasStore.getState().nestNode("b", "a");
|
||||
|
||||
const b = useCanvasStore.getState().nodes.find((n) => n.id === "b")!;
|
||||
expect(b.data.parentId).toBe("a");
|
||||
expect(b.hidden).toBe(true);
|
||||
expect(b.parentId).toBe("a");
|
||||
});
|
||||
|
||||
it("un-nesting sets parentId to null and shows node", async () => {
|
||||
// First nest
|
||||
it("un-nesting clears parentId and the RF binding", async () => {
|
||||
await useCanvasStore.getState().nestNode("b", "a");
|
||||
// Then un-nest
|
||||
await useCanvasStore.getState().nestNode("b", null);
|
||||
|
||||
const b = useCanvasStore.getState().nodes.find((n) => n.id === "b")!;
|
||||
expect(b.data.parentId).toBeNull();
|
||||
expect(b.hidden).toBe(false);
|
||||
expect(b.parentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips when parentId is already the target", async () => {
|
||||
@ -694,7 +698,7 @@ describe("nestNode", () => {
|
||||
// Should revert to original state (no parent)
|
||||
const b = useCanvasStore.getState().nodes.find((n) => n.id === "b")!;
|
||||
expect(b.data.parentId).toBeNull();
|
||||
expect(b.hidden).toBe(false);
|
||||
expect(b.parentId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -851,3 +855,205 @@ describe("TASK_UPDATED edge cases", () => {
|
||||
expect(ws2.data.currentTask).toBe("Task B"); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- setCollapsed round-trip ----------
|
||||
|
||||
describe("setCollapsed", () => {
|
||||
beforeEach(() => {
|
||||
// Three-level chain so we can test that collapsing an ancestor
|
||||
// hides all descendants AND that expanding it correctly preserves
|
||||
// any intermediate collapsed state (otherwise setCollapsed and
|
||||
// hydrate produce different hidden flags — the drift the review
|
||||
// flagged as Critical).
|
||||
useCanvasStore.getState().hydrate([
|
||||
makeWS({ id: "a", name: "A" }),
|
||||
makeWS({ id: "b", name: "B", parent_id: "a" }),
|
||||
makeWS({ id: "c", name: "C", parent_id: "b" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides the entire subtree when the root is collapsed", () => {
|
||||
useCanvasStore.getState().setCollapsed("a", true);
|
||||
const { nodes } = useCanvasStore.getState();
|
||||
expect(nodes.find((n) => n.id === "a")!.hidden).toBeFalsy();
|
||||
expect(nodes.find((n) => n.id === "b")!.hidden).toBe(true);
|
||||
expect(nodes.find((n) => n.id === "c")!.hidden).toBe(true);
|
||||
expect(nodes.find((n) => n.id === "a")!.data.collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps descendants hidden when an ancestor is un-collapsed but a middle parent is still collapsed", () => {
|
||||
// Collapse both A and B, then expand A. C must stay hidden because
|
||||
// B — its immediate parent — is still collapsed. Before the fix,
|
||||
// setCollapsed naively unhid every descendant of A and drifted from
|
||||
// what hydrate would produce.
|
||||
useCanvasStore.getState().setCollapsed("a", true);
|
||||
useCanvasStore.getState().setCollapsed("b", true);
|
||||
useCanvasStore.getState().setCollapsed("a", false);
|
||||
const { nodes } = useCanvasStore.getState();
|
||||
expect(nodes.find((n) => n.id === "b")!.hidden).toBeFalsy();
|
||||
expect(nodes.find((n) => n.id === "c")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("matches hydrate's hidden flags (no drift on snapshot refresh)", () => {
|
||||
// Run the same scenario through setCollapsed, then re-hydrate from
|
||||
// an equivalent server snapshot and assert the hidden flags agree.
|
||||
useCanvasStore.getState().setCollapsed("a", true);
|
||||
const afterCollapse = useCanvasStore.getState().nodes.map((n) => ({
|
||||
id: n.id,
|
||||
hidden: !!n.hidden,
|
||||
}));
|
||||
|
||||
useCanvasStore.getState().hydrate([
|
||||
makeWS({ id: "a", name: "A", collapsed: true }),
|
||||
makeWS({ id: "b", name: "B", parent_id: "a" }),
|
||||
makeWS({ id: "c", name: "C", parent_id: "b" }),
|
||||
]);
|
||||
const afterHydrate = useCanvasStore.getState().nodes.map((n) => ({
|
||||
id: n.id,
|
||||
hidden: !!n.hidden,
|
||||
}));
|
||||
expect(afterHydrate).toEqual(afterCollapse);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- bumpZOrder ----------
|
||||
|
||||
describe("bumpZOrder", () => {
|
||||
beforeEach(() => {
|
||||
useCanvasStore.getState().hydrate([
|
||||
makeWS({ id: "r1", name: "R1" }),
|
||||
makeWS({ id: "r2", name: "R2" }),
|
||||
makeWS({ id: "r3", name: "R3" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("swaps with the neighbour in the bump direction (no drift on identical zIndex)", () => {
|
||||
// Fresh topology: all three siblings start at zIndex=0 (depth=0).
|
||||
// Bumping r2 forward must put it above exactly one sibling, not
|
||||
// arbitrarily far ahead.
|
||||
useCanvasStore.getState().bumpZOrder("r2", 1);
|
||||
const nodes = useCanvasStore.getState().nodes;
|
||||
const r1Z = nodes.find((n) => n.id === "r1")!.zIndex ?? 0;
|
||||
const r2Z = nodes.find((n) => n.id === "r2")!.zIndex ?? 0;
|
||||
const r3Z = nodes.find((n) => n.id === "r3")!.zIndex ?? 0;
|
||||
// r2 now above at least one neighbour.
|
||||
expect(r2Z).toBeGreaterThan(Math.min(r1Z, r3Z));
|
||||
// Bumping once more swaps with the remaining one — not unbounded.
|
||||
useCanvasStore.getState().bumpZOrder("r2", 1);
|
||||
const r2ZAfter = useCanvasStore.getState().nodes.find((n) => n.id === "r2")!.zIndex ?? 0;
|
||||
expect(r2ZAfter).toBeLessThanOrEqual(r2Z + 2);
|
||||
});
|
||||
|
||||
it("no-ops at the edge of the sibling list", () => {
|
||||
const beforeZ = useCanvasStore.getState().nodes.map((n) => n.zIndex ?? 0);
|
||||
// First sibling bumped backward has no earlier neighbour.
|
||||
useCanvasStore.getState().bumpZOrder("r1", -1);
|
||||
const afterZ = useCanvasStore.getState().nodes.map((n) => n.zIndex ?? 0);
|
||||
expect(afterZ).toEqual(beforeZ);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- batchNest ----------
|
||||
|
||||
describe("batchNest", () => {
|
||||
beforeEach(() => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
|
||||
// Scenario: two root nodes (a, b) and one nested under a (a-child).
|
||||
// Tests below re-parent various subsets into `target`.
|
||||
useCanvasStore.getState().hydrate([
|
||||
makeWS({ id: "target", name: "Target", x: 1000, y: 0 }),
|
||||
makeWS({ id: "a", name: "A", x: 0, y: 0 }),
|
||||
makeWS({ id: "b", name: "B", x: 200, y: 0 }),
|
||||
makeWS({ id: "a-child", name: "A/Child", parent_id: "a", x: 50, y: 50 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-parents every selected root into the target via one PATCH each", async () => {
|
||||
const mock = global.fetch as ReturnType<typeof vi.fn>;
|
||||
mock.mockImplementation(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response),
|
||||
);
|
||||
// Clear any PATCHes that hydrate's computeAutoLayout may have fired
|
||||
// (auto-positioned workspaces trigger a savePosition → PATCH).
|
||||
mock.mockClear();
|
||||
await useCanvasStore.getState().batchNest(["a", "b"], "target");
|
||||
const nodes = useCanvasStore.getState().nodes;
|
||||
expect(nodes.find((n) => n.id === "a")!.data.parentId).toBe("target");
|
||||
expect(nodes.find((n) => n.id === "b")!.data.parentId).toBe("target");
|
||||
// Every PATCH fired by batchNest should target /workspaces/<id>
|
||||
// and carry `parent_id: "target"` plus absolute x,y. One per root.
|
||||
const nestPatchCalls = mock.mock.calls.filter((c) => {
|
||||
const init = c[1] as RequestInit | undefined;
|
||||
if (init?.method !== "PATCH") return false;
|
||||
const body = init.body ? JSON.parse(init.body as string) : {};
|
||||
return body.parent_id === "target";
|
||||
});
|
||||
expect(nestPatchCalls).toHaveLength(2);
|
||||
for (const call of nestPatchCalls) {
|
||||
const body = JSON.parse((call[1] as RequestInit).body as string);
|
||||
expect(body.x).toBeTypeOf("number");
|
||||
expect(body.y).toBeTypeOf("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("filters out selected descendants so a subtree moves intact", async () => {
|
||||
// User selects both A AND its child A/Child, then drags into target.
|
||||
// Intent: move the A subtree — A/Child stays under A, not target.
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response),
|
||||
);
|
||||
await useCanvasStore.getState().batchNest(["a", "a-child"], "target");
|
||||
const nodes = useCanvasStore.getState().nodes;
|
||||
expect(nodes.find((n) => n.id === "a")!.data.parentId).toBe("target");
|
||||
// The descendant is NOT independently re-parented; its parent is still A.
|
||||
expect(nodes.find((n) => n.id === "a-child")!.data.parentId).toBe("a");
|
||||
});
|
||||
|
||||
it("rolls back only the nodes whose PATCH rejected", async () => {
|
||||
// Reject the PATCH for `a`, accept the one for `b`.
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
if (typeof url === "string" && url.endsWith("/workspaces/a")) {
|
||||
return Promise.reject(new Error("network"));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
} as Response);
|
||||
});
|
||||
await useCanvasStore.getState().batchNest(["a", "b"], "target");
|
||||
const nodes = useCanvasStore.getState().nodes;
|
||||
// `a` rolled back to its original parent (null), `b` stayed committed.
|
||||
expect(nodes.find((n) => n.id === "a")!.data.parentId).toBeNull();
|
||||
expect(nodes.find((n) => n.id === "b")!.data.parentId).toBe("target");
|
||||
});
|
||||
|
||||
it("filters out all selected descendants in a three-level chain", async () => {
|
||||
// Re-hydrate to a chain A → B → C. User selects all three.
|
||||
// Expected: only A is planned for re-parent; B and C ride with it
|
||||
// via React Flow's parent binding.
|
||||
useCanvasStore.getState().hydrate([
|
||||
makeWS({ id: "target", name: "Target", x: 2000, y: 0 }),
|
||||
makeWS({ id: "A", name: "A", x: 0, y: 0 }),
|
||||
makeWS({ id: "B", name: "B", parent_id: "A", x: 50, y: 50 }),
|
||||
makeWS({ id: "C", name: "C", parent_id: "B", x: 10, y: 10 }),
|
||||
]);
|
||||
const mock = global.fetch as ReturnType<typeof vi.fn>;
|
||||
mock.mockImplementation(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response),
|
||||
);
|
||||
mock.mockClear();
|
||||
await useCanvasStore.getState().batchNest(["A", "B", "C"], "target");
|
||||
const nodes = useCanvasStore.getState().nodes;
|
||||
expect(nodes.find((n) => n.id === "A")!.data.parentId).toBe("target");
|
||||
expect(nodes.find((n) => n.id === "B")!.data.parentId).toBe("A");
|
||||
expect(nodes.find((n) => n.id === "C")!.data.parentId).toBe("B");
|
||||
// Exactly one nest-PATCH (for A). B and C weren't re-parented.
|
||||
const nestPatches = mock.mock.calls.filter((c) => {
|
||||
const init = c[1] as RequestInit | undefined;
|
||||
if (init?.method !== "PATCH") return false;
|
||||
const body = init.body ? JSON.parse(init.body as string) : {};
|
||||
return body.parent_id === "target";
|
||||
});
|
||||
expect(nestPatches).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -174,7 +174,7 @@ export function handleCanvasEvent(
|
||||
n.data.parentId === msg.workspace_id
|
||||
? {
|
||||
...n,
|
||||
hidden: !!parentOfRemoved,
|
||||
parentId: parentOfRemoved ?? undefined,
|
||||
data: { ...n.data, parentId: parentOfRemoved },
|
||||
}
|
||||
: n
|
||||
|
||||
@ -5,6 +5,85 @@ import type { WorkspaceNodeData } from "./canvas";
|
||||
const H_SPACING = 320;
|
||||
const V_SPACING = 200;
|
||||
|
||||
// Default card footprint we use when we don't yet have a measured size
|
||||
// (first render, before React Flow reports dimensions). These match the
|
||||
// min-width / min-height that WorkspaceNode.tsx sets, so a parent built
|
||||
// from them will never start too small for its children on first paint.
|
||||
/**
|
||||
* Re-orders a React Flow node array so parents always appear BEFORE
|
||||
* their children. React Flow requires this ordering; when it's
|
||||
* violated RF logs "Parent node ... not found" and renders the child
|
||||
* at canvas-absolute coords (losing the parent-relative transform).
|
||||
*
|
||||
* We call this every time nestNode / batchNest mutates parentId —
|
||||
* without a re-sort a freshly-nested child can appear AFTER its new
|
||||
* parent in the array, which breaks the next drag.
|
||||
*/
|
||||
export function sortParentsBeforeChildren<T extends { id: string; parentId?: string }>(
|
||||
nodes: T[],
|
||||
): T[] {
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const visited = new Set<string>();
|
||||
const out: T[] = [];
|
||||
const visit = (n: T) => {
|
||||
if (visited.has(n.id)) return;
|
||||
if (n.parentId) {
|
||||
const parent = byId.get(n.parentId);
|
||||
if (parent && !visited.has(parent.id)) visit(parent);
|
||||
}
|
||||
visited.add(n.id);
|
||||
out.push(n);
|
||||
};
|
||||
for (const n of nodes) visit(n);
|
||||
return out;
|
||||
}
|
||||
|
||||
export const CHILD_DEFAULT_WIDTH = 260;
|
||||
export const CHILD_DEFAULT_HEIGHT = 140;
|
||||
export const PARENT_HEADER_PADDING = 60; // room for the parent's own header
|
||||
export const PARENT_SIDE_PADDING = 20;
|
||||
export const PARENT_BOTTOM_PADDING = 20;
|
||||
export const CHILD_GUTTER = 20;
|
||||
|
||||
|
||||
/**
|
||||
* A deterministic grid slot for the n-th child inside a parent, counted
|
||||
* left-to-right then top-to-bottom. Used to lay out org-imported teams
|
||||
* and to rescue children whose stored position puts them outside the
|
||||
* parent's bounding box. 2-column grid is wide enough to read but
|
||||
* narrow enough to keep the parent card from becoming a widescreen.
|
||||
*/
|
||||
export function defaultChildSlot(index: number): { x: number; y: number } {
|
||||
const col = index % 2;
|
||||
const row = Math.floor(index / 2);
|
||||
const x = PARENT_SIDE_PADDING + col * (CHILD_DEFAULT_WIDTH + CHILD_GUTTER);
|
||||
const y =
|
||||
PARENT_HEADER_PADDING + row * (CHILD_DEFAULT_HEIGHT + CHILD_GUTTER);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum parent size that still fits `childCount` children laid out via
|
||||
* defaultChildSlot. Never shrinks below the leaf-card min.
|
||||
*/
|
||||
export function parentMinSize(childCount: number): { width: number; height: number } {
|
||||
if (childCount <= 0) {
|
||||
return { width: 210, height: 120 };
|
||||
}
|
||||
const cols = Math.min(2, childCount);
|
||||
const rows = Math.ceil(childCount / 2);
|
||||
const width =
|
||||
PARENT_SIDE_PADDING * 2 +
|
||||
cols * CHILD_DEFAULT_WIDTH +
|
||||
(cols - 1) * CHILD_GUTTER;
|
||||
const height =
|
||||
PARENT_HEADER_PADDING +
|
||||
rows * CHILD_DEFAULT_HEIGHT +
|
||||
(rows - 1) * CHILD_GUTTER +
|
||||
PARENT_BOTTOM_PADDING;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes auto-layout positions for workspaces that have no persisted position
|
||||
* (x === 0 AND y === 0). Workspaces with an existing non-zero position are used
|
||||
@ -109,6 +188,14 @@ export function computeAutoLayout(
|
||||
* Converts raw workspace data from the API into React Flow nodes and edges.
|
||||
* Accepts an optional layoutOverrides map (from computeAutoLayout) to override
|
||||
* positions for workspaces that were at 0,0.
|
||||
*
|
||||
* Parent/child rendering model: every workspace is a first-class React Flow
|
||||
* node (full card). When a workspace has parent_id set, its RF `parentId` is
|
||||
* set to the parent's id and its position is stored RELATIVE to the parent
|
||||
* origin — React Flow renders the child inside the parent's coordinate space,
|
||||
* so moving the parent automatically moves all children. The DB keeps
|
||||
* absolute x/y; the abs→rel conversion happens here on load, and the
|
||||
* reverse translation happens in savePosition.
|
||||
*/
|
||||
export function buildNodesAndEdges(
|
||||
workspaces: WorkspaceData[],
|
||||
@ -117,16 +204,134 @@ export function buildNodesAndEdges(
|
||||
nodes: Node<WorkspaceNodeData>[];
|
||||
edges: Edge[];
|
||||
} {
|
||||
// All workspaces become nodes (children are rendered inside parent via WorkspaceNode)
|
||||
const nodes: Node<WorkspaceNodeData>[] = workspaces.map((ws) => {
|
||||
const override = layoutOverrides.get(ws.id);
|
||||
const x = override?.x ?? ws.x;
|
||||
const y = override?.y ?? ws.y;
|
||||
return {
|
||||
// React Flow requires parent nodes to appear before children in the nodes
|
||||
// array. Topological-sort by depth-first walk from roots so children come
|
||||
// after their parent regardless of the order the API returned them.
|
||||
const byId = new Map(workspaces.map((w) => [w.id, w]));
|
||||
const visited = new Set<string>();
|
||||
const sorted: WorkspaceData[] = [];
|
||||
function visit(ws: WorkspaceData) {
|
||||
if (visited.has(ws.id)) return;
|
||||
if (ws.parent_id && byId.has(ws.parent_id) && !visited.has(ws.parent_id)) {
|
||||
visit(byId.get(ws.parent_id)!);
|
||||
}
|
||||
visited.add(ws.id);
|
||||
sorted.push(ws);
|
||||
}
|
||||
workspaces.forEach(visit);
|
||||
|
||||
// Resolve each workspace's absolute position (apply layout override if any).
|
||||
const absPos = new Map<string, { x: number; y: number }>();
|
||||
for (const ws of workspaces) {
|
||||
const o = layoutOverrides.get(ws.id);
|
||||
absPos.set(ws.id, { x: o?.x ?? ws.x, y: o?.y ?? ws.y });
|
||||
}
|
||||
|
||||
// Count children per parent so we can size parents to fit their team
|
||||
// before any runtime measurement comes back.
|
||||
const childCounts = new Map<string, number>();
|
||||
for (const ws of workspaces) {
|
||||
if (ws.parent_id) {
|
||||
childCounts.set(ws.parent_id, (childCounts.get(ws.parent_id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Track each parent's initial size so we can reset children that land
|
||||
// outside those bounds. Parents without children fall back to the leaf
|
||||
// default; parents with children get the grid-derived minimum.
|
||||
const parentSize = new Map<string, { width: number; height: number }>();
|
||||
for (const ws of workspaces) {
|
||||
const n = childCounts.get(ws.id) ?? 0;
|
||||
parentSize.set(ws.id, n > 0 ? parentMinSize(n) : { width: 260, height: 140 });
|
||||
}
|
||||
|
||||
// Running index of children already placed per parent — used to hand
|
||||
// out default grid slots for children whose stored position is outside
|
||||
// the parent's computed box.
|
||||
const nextChildIndex = new Map<string, number>();
|
||||
|
||||
// Depth per node so children always render above parents (and above
|
||||
// parent's root-level siblings). React Flow uses a flat zIndex, so a
|
||||
// child inherits zIndex = parent.zIndex + 1 — xyflow issue #4012.
|
||||
const depthById = new Map<string, number>();
|
||||
for (const ws of sorted) {
|
||||
const d = ws.parent_id ? (depthById.get(ws.parent_id) ?? 0) + 1 : 0;
|
||||
depthById.set(ws.id, d);
|
||||
}
|
||||
|
||||
// Mark each node as hidden if any ancestor is collapsed. Walk from
|
||||
// the root so children inherit the flag efficiently. (Parents stay
|
||||
// visible; only descendants are hidden so the parent renders as a
|
||||
// compact header-only card.)
|
||||
const hiddenById = new Map<string, boolean>();
|
||||
for (const ws of sorted) {
|
||||
if (!ws.parent_id) {
|
||||
hiddenById.set(ws.id, false);
|
||||
continue;
|
||||
}
|
||||
const parent = byId.get(ws.parent_id);
|
||||
const parentHidden = hiddenById.get(ws.parent_id) ?? false;
|
||||
hiddenById.set(ws.id, parentHidden || !!parent?.collapsed);
|
||||
}
|
||||
|
||||
const nodes: Node<WorkspaceNodeData>[] = sorted.map((ws) => {
|
||||
const abs = absPos.get(ws.id)!;
|
||||
const hasParent = !!ws.parent_id && byId.has(ws.parent_id);
|
||||
let position = abs;
|
||||
if (hasParent) {
|
||||
const pa = absPos.get(ws.parent_id!)!;
|
||||
position = { x: abs.x - pa.x, y: abs.y - pa.y };
|
||||
|
||||
// Auto-rescue on load: fires only when the child's bounding box
|
||||
// is FULLY outside the parent's computed bbox by at least one
|
||||
// child-width/height. Two real failure modes this covers:
|
||||
//
|
||||
// - Legacy data: a child whose stored absolute coords predate
|
||||
// the nesting assignment, so abs→rel produces a huge offset
|
||||
// far past any parent edge.
|
||||
// - Corrupt org-imports with positions in a different
|
||||
// coordinate space.
|
||||
//
|
||||
// Rejected heuristics we deliberately avoid:
|
||||
// - `position.x < 0` alone — catches legitimate drift when the
|
||||
// user drags the parent past a child that had small positive
|
||||
// stored coords (child's relative goes mildly negative, but
|
||||
// the layout is still recoverable).
|
||||
// - Raw magnitude like `> 8000` — doesn't scale with parent
|
||||
// size; a user who resized the parent huge could legitimately
|
||||
// place a child at 9000px.
|
||||
//
|
||||
// Children slightly past the initial min-size (user had resized
|
||||
// the parent larger on a previous session) are NEVER rescued —
|
||||
// the bbox-overlap test gives them room. The manual "Arrange
|
||||
// Children" context command is still the escape hatch for that
|
||||
// bucket of data.
|
||||
// Pure bbox-overlap test — self-calibrating without a magic
|
||||
// margin. Rescue iff the child's bbox has ZERO overlap with the
|
||||
// parent's bbox (the child would render completely detached).
|
||||
// drift case (position.x = -40, CHILD_WIDTH = 260):
|
||||
// child.right = 220, overlaps parent.left = 0 → kept
|
||||
// screenshot case (position.x = -420, CHILD_WIDTH = 260):
|
||||
// child.right = -160, doesn't overlap parent.left = 0 → rescued
|
||||
// user resized larger (parent.width now 800, position.x = 500):
|
||||
// child.left = 500 < parent.right = 800 → overlaps → kept
|
||||
// legacy huge positive (position.x = 50000):
|
||||
// child.left = 50000 >= parent.right → no overlap → rescued
|
||||
const psize = parentSize.get(ws.parent_id!)!;
|
||||
const overlapsX =
|
||||
position.x + CHILD_DEFAULT_WIDTH > 0 && position.x < psize.width;
|
||||
const overlapsY =
|
||||
position.y + CHILD_DEFAULT_HEIGHT > 0 && position.y < psize.height;
|
||||
if (!overlapsX || !overlapsY) {
|
||||
const idx = nextChildIndex.get(ws.parent_id!) ?? 0;
|
||||
nextChildIndex.set(ws.parent_id!, idx + 1);
|
||||
position = defaultChildSlot(idx);
|
||||
}
|
||||
}
|
||||
const node: Node<WorkspaceNodeData> = {
|
||||
id: ws.id,
|
||||
type: "workspaceNode",
|
||||
position: { x, y },
|
||||
// Don't set React Flow parentId — children render embedded inside the WorkspaceNode component
|
||||
position,
|
||||
data: {
|
||||
name: ws.name,
|
||||
status: ws.status,
|
||||
@ -145,13 +350,35 @@ export function buildNodesAndEdges(
|
||||
budgetLimit: ws.budget_limit ?? null,
|
||||
budgetUsed: ws.budget_used ?? null,
|
||||
},
|
||||
// Hide child nodes from canvas — they render inside the parent WorkspaceNode
|
||||
hidden: !!ws.parent_id,
|
||||
};
|
||||
if (hasParent) {
|
||||
// React Flow native parent binding: children render inside parent's
|
||||
// coordinate space and move with the parent. No `extent: 'parent'` —
|
||||
// the user can drag a child out to un-nest (handled in Canvas.tsx
|
||||
// onNodeDragStop with a bbox hit test).
|
||||
node.parentId = ws.parent_id!;
|
||||
}
|
||||
// Stack children above their ancestors (xyflow #4012).
|
||||
node.zIndex = depthById.get(ws.id) ?? 0;
|
||||
// Collapse: descendants of a collapsed parent get hidden so the
|
||||
// parent renders as a compact header-only card.
|
||||
if (hiddenById.get(ws.id)) {
|
||||
node.hidden = true;
|
||||
}
|
||||
// Give parents a measured-ish starting size so NodeResizer has a
|
||||
// baseline and child positions have somewhere to live. Without this,
|
||||
// parents start at React Flow's default min size (well under a
|
||||
// single child) and children render visually outside their parent
|
||||
// until the next resize measurement settles.
|
||||
if ((childCounts.get(ws.id) ?? 0) > 0) {
|
||||
const size = parentSize.get(ws.id)!;
|
||||
node.width = size.width;
|
||||
node.height = size.height;
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
// No parent→child edges — children are embedded inside the parent node.
|
||||
// Only create edges between siblings or cross-team connections if needed in future.
|
||||
// Edges stay empty — the visual parent/child cue is the enclosing card.
|
||||
const edges: Edge[] = [];
|
||||
|
||||
return { nodes, edges };
|
||||
|
||||
@ -6,9 +6,67 @@ import {
|
||||
type NodeChange,
|
||||
} from "@xyflow/react";
|
||||
import { api } from "@/lib/api";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import type { WorkspaceData, WSMessage } from "./socket";
|
||||
import { handleCanvasEvent } from "./canvas-events";
|
||||
import { buildNodesAndEdges, computeAutoLayout } from "./canvas-topology";
|
||||
import {
|
||||
buildNodesAndEdges,
|
||||
computeAutoLayout,
|
||||
defaultChildSlot,
|
||||
sortParentsBeforeChildren,
|
||||
CHILD_DEFAULT_HEIGHT,
|
||||
CHILD_DEFAULT_WIDTH,
|
||||
PARENT_BOTTOM_PADDING,
|
||||
PARENT_SIDE_PADDING,
|
||||
} from "./canvas-topology";
|
||||
|
||||
/**
|
||||
* Walk every parent node and bump its width/height (if explicitly set)
|
||||
* so the union of its children's relative bboxes plus padding fits. A
|
||||
* parent's size never shrinks via this path — only grows — because
|
||||
* shrinking on resize would fight the user's own NodeResizer drag.
|
||||
*/
|
||||
function growParentsToFitChildren<T extends Record<string, unknown>>(
|
||||
nodes: Node<T>[],
|
||||
): Node<T>[] {
|
||||
// Index children by parentId so the scan is O(n).
|
||||
const childrenByParent = new Map<string, Node<T>[]>();
|
||||
for (const n of nodes) {
|
||||
if (!n.parentId) continue;
|
||||
const arr = childrenByParent.get(n.parentId) ?? [];
|
||||
arr.push(n);
|
||||
childrenByParent.set(n.parentId, arr);
|
||||
}
|
||||
let changed = false;
|
||||
const out = nodes.map((n) => {
|
||||
const kids = childrenByParent.get(n.id);
|
||||
if (!kids || kids.length === 0) return n;
|
||||
// Collapsed parents intentionally render compact — skip the grow
|
||||
// pass so their size isn't pushed back out by their hidden kids.
|
||||
const nData = n.data as unknown as WorkspaceNodeData | undefined;
|
||||
if (nData?.collapsed) return n;
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
for (const k of kids) {
|
||||
const w = (k.measured?.width ?? k.width ?? CHILD_DEFAULT_WIDTH) as number;
|
||||
const h = (k.measured?.height ?? k.height ?? CHILD_DEFAULT_HEIGHT) as number;
|
||||
maxRight = Math.max(maxRight, k.position.x + w);
|
||||
maxBottom = Math.max(maxBottom, k.position.y + h);
|
||||
}
|
||||
const requiredW = maxRight + PARENT_SIDE_PADDING;
|
||||
const requiredH = maxBottom + PARENT_BOTTOM_PADDING;
|
||||
const currentW = (n.measured?.width ?? n.width ?? 0) as number;
|
||||
const currentH = (n.measured?.height ?? n.height ?? 0) as number;
|
||||
if (requiredW <= currentW && requiredH <= currentH) return n;
|
||||
changed = true;
|
||||
return {
|
||||
...n,
|
||||
width: Math.max(currentW, requiredW),
|
||||
height: Math.max(currentH, requiredH),
|
||||
};
|
||||
});
|
||||
return changed ? out : nodes;
|
||||
}
|
||||
|
||||
// Re-export extracted types and functions so existing imports from "@/store/canvas" keep working
|
||||
export { summarizeWorkspaceCapabilities } from "./canvas-capabilities";
|
||||
@ -76,6 +134,28 @@ interface CanvasState {
|
||||
setDragOverNode: (id: string | null) => void;
|
||||
nestNode: (draggedId: string, targetId: string | null) => Promise<void>;
|
||||
isDescendant: (ancestorId: string, nodeId: string) => boolean;
|
||||
/** Re-order siblings in z-index space. `direction = +1` sends the node
|
||||
* one step forward among its parent's children (or among canvas
|
||||
* roots); -1 sends it one step back. Figma Cmd+]/[ parity. */
|
||||
bumpZOrder: (nodeId: string, direction: 1 | -1) => void;
|
||||
/** Re-parent many nodes at once, preserving each node's absolute
|
||||
* position. Lucidchart pattern: drag a selection into a frame and
|
||||
* the inter-node layout stays intact. Used when the primary dragged
|
||||
* node of a multi-select drag triggers a nest confirmation. */
|
||||
batchNest: (nodeIds: string[], targetId: string | null) => Promise<void>;
|
||||
/** Run the parent auto-grow pass once. Canvas.onNodeDragStop calls
|
||||
* this so a drag that pushed a child past the parent edge commits
|
||||
* the parent grow on release (commit-on-release pattern). */
|
||||
growParentsToFitChildren: () => void;
|
||||
/** Re-layout a parent's children to the default 2-column grid. Used
|
||||
* by the "Arrange children" context-menu command so users can rescue
|
||||
* out-of-bounds children on demand — topology no longer does it
|
||||
* automatically (P3.12 opt-in rescue). */
|
||||
arrangeChildren: (parentId: string) => void;
|
||||
/** Toggle the collapsed flag on a parent and hide/show every
|
||||
* descendant so the card renders as a compact header-only frame.
|
||||
* Miro "frame outline view" analog. */
|
||||
setCollapsed: (parentId: string, collapsed: boolean) => void;
|
||||
openContextMenu: (menu: ContextMenuState) => void;
|
||||
closeContextMenu: () => void;
|
||||
// Pending delete confirmation — lives in the store (not inside ContextMenu's
|
||||
@ -246,6 +326,256 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
setPanelTab: (tab) => set({ panelTab: tab }),
|
||||
setDragOverNode: (id) => set({ dragOverNodeId: id }),
|
||||
|
||||
batchNest: async (nodeIds, targetId) => {
|
||||
if (nodeIds.length === 0) return;
|
||||
// Selection-roots filter: if the user selected both A and A's
|
||||
// descendant B and dragged the pair into T, the intent is "move
|
||||
// the subtree" — B should stay under A, not become a sibling of
|
||||
// A under T. Drop every selected node whose ancestor is also
|
||||
// selected; those will follow their ancestor via React Flow's
|
||||
// parent-of binding automatically.
|
||||
const selectedSet = new Set(nodeIds);
|
||||
const { nodes: before, edges: beforeEdges } = get();
|
||||
const byId = new Map(before.map((n) => [n.id, n]));
|
||||
const rootsOnly: string[] = [];
|
||||
for (const id of nodeIds) {
|
||||
let cursor = byId.get(id)?.data.parentId ?? null;
|
||||
let hasSelectedAncestor = false;
|
||||
// Seen-set guards against a corrupt parentId cycle. Shouldn't
|
||||
// happen with a healthy backend — nestNode itself blocks cycles
|
||||
// via isDescendant — but this walk is user-triggered and the
|
||||
// cost of the guard is one set allocation per selected node.
|
||||
const seen = new Set<string>();
|
||||
while (cursor && !seen.has(cursor)) {
|
||||
seen.add(cursor);
|
||||
if (selectedSet.has(cursor)) {
|
||||
hasSelectedAncestor = true;
|
||||
break;
|
||||
}
|
||||
cursor = byId.get(cursor)?.data.parentId ?? null;
|
||||
}
|
||||
if (!hasSelectedAncestor) rootsOnly.push(id);
|
||||
}
|
||||
if (rootsOnly.length === 0) return;
|
||||
if (rootsOnly.length === 1) {
|
||||
await get().nestNode(rootsOnly[0], targetId);
|
||||
return;
|
||||
}
|
||||
// Batch path: do all state math against one snapshot so every
|
||||
// selected node sees the same "before" world, commit one set(),
|
||||
// then fire every PATCH in parallel. Previously this called
|
||||
// nestNode sequentially, which cost 2N round-trips (parent_id +
|
||||
// x/y) strictly serialized; now it's 1 round-trip per node, all
|
||||
// in flight at once. For a typical 3-5 node selection on a
|
||||
// ~200ms link this drops the perceived re-parent latency from
|
||||
// ~2s to ~200ms.
|
||||
|
||||
const absOf = (id: string | null | undefined): { x: number; y: number } => {
|
||||
let sum = { x: 0, y: 0 };
|
||||
let cursor: string | null | undefined = id;
|
||||
while (cursor) {
|
||||
const n = byId.get(cursor);
|
||||
if (!n) break;
|
||||
sum = { x: sum.x + n.position.x, y: sum.y + n.position.y };
|
||||
cursor = n.data.parentId;
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
const depthOf = (id: string | null | undefined): number => {
|
||||
let d = 0;
|
||||
let cursor: string | null | undefined = id;
|
||||
while (cursor) {
|
||||
const n = byId.get(cursor);
|
||||
if (!n) break;
|
||||
cursor = n.data.parentId;
|
||||
d += 1;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
const newParentAbs = absOf(targetId);
|
||||
const newOwnDepth = targetId ? depthOf(targetId) + 1 : 0;
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
newRelative: { x: number; y: number };
|
||||
draggedAbs: { x: number; y: number };
|
||||
depthDelta: number;
|
||||
}
|
||||
const plan: Plan[] = [];
|
||||
const movedIds = new Set<string>();
|
||||
// Filter out nodes that would be invalid targets / no-ops.
|
||||
for (const id of rootsOnly) {
|
||||
const dragged = byId.get(id);
|
||||
if (!dragged) continue;
|
||||
const currentParentId = dragged.data.parentId;
|
||||
if (currentParentId === targetId) continue;
|
||||
// Can't nest into yourself or your own descendant.
|
||||
if (targetId && get().isDescendant(id, targetId)) continue;
|
||||
const oldParentAbs = absOf(currentParentId);
|
||||
const draggedAbs = {
|
||||
x: dragged.position.x + oldParentAbs.x,
|
||||
y: dragged.position.y + oldParentAbs.y,
|
||||
};
|
||||
const newRelative = {
|
||||
x: draggedAbs.x - newParentAbs.x,
|
||||
y: draggedAbs.y - newParentAbs.y,
|
||||
};
|
||||
const oldOwnDepth =
|
||||
dragged.zIndex ?? depthOf(currentParentId) + (currentParentId ? 1 : 0);
|
||||
plan.push({
|
||||
id,
|
||||
newRelative,
|
||||
draggedAbs,
|
||||
depthDelta: newOwnDepth - oldOwnDepth,
|
||||
});
|
||||
movedIds.add(id);
|
||||
// Every descendant of a moved node also shifts by the same delta
|
||||
// so grandchildren don't fall behind their re-parented ancestor.
|
||||
const bfs = [id];
|
||||
while (bfs.length) {
|
||||
const head = bfs.shift()!;
|
||||
for (const n of before) {
|
||||
if (n.data.parentId === head && !movedIds.has(n.id)) {
|
||||
movedIds.add(n.id);
|
||||
bfs.push(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.length === 0) return;
|
||||
const planById = new Map(plan.map((p) => [p.id, p]));
|
||||
|
||||
// One optimistic set() covers every re-parent + every descendant
|
||||
// zIndex shift; no further state mutations before the PATCHes come
|
||||
// back (failed PATCHes roll back individual nodes below).
|
||||
set({
|
||||
nodes: before.map((n) => {
|
||||
const p = planById.get(n.id);
|
||||
if (p) {
|
||||
return {
|
||||
...n,
|
||||
position: p.newRelative,
|
||||
parentId: targetId ?? undefined,
|
||||
zIndex: newOwnDepth,
|
||||
data: { ...n.data, parentId: targetId },
|
||||
};
|
||||
}
|
||||
// Descendant of a moved node — shift zIndex only. Find the
|
||||
// nearest ancestor in `plan` (walking up parents) to know
|
||||
// which depthDelta applies.
|
||||
if (movedIds.has(n.id)) {
|
||||
let cursor: string | null | undefined = n.data.parentId;
|
||||
while (cursor) {
|
||||
const anc = planById.get(cursor);
|
||||
if (anc) {
|
||||
if (anc.depthDelta === 0) break;
|
||||
return { ...n, zIndex: (n.zIndex ?? 0) + anc.depthDelta };
|
||||
}
|
||||
cursor = byId.get(cursor)?.data.parentId ?? null;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
return n;
|
||||
}),
|
||||
edges: beforeEdges.filter(
|
||||
(e) => !movedIds.has(e.source) && !movedIds.has(e.target),
|
||||
),
|
||||
});
|
||||
// Keep parents before children in the array (same invariant
|
||||
// nestNode enforces). Needed after multi-select re-parent because
|
||||
// the selection order is user-driven.
|
||||
set({ nodes: sortParentsBeforeChildren(get().nodes) });
|
||||
|
||||
// Fire every PATCH in parallel. Individual failures roll back just
|
||||
// that node (others remain committed, matching the single-node
|
||||
// rollback behaviour in nestNode).
|
||||
const results = await Promise.allSettled(
|
||||
plan.map((p) =>
|
||||
api.patch(`/workspaces/${p.id}`, {
|
||||
parent_id: targetId,
|
||||
x: p.draggedAbs.x,
|
||||
y: p.draggedAbs.y,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const rolledBack: string[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (results[i].status === "rejected") rolledBack.push(plan[i].id);
|
||||
}
|
||||
if (rolledBack.length > 0) {
|
||||
const rollbackSet = new Set(rolledBack);
|
||||
set({
|
||||
nodes: get().nodes.map((n) => {
|
||||
if (!rollbackSet.has(n.id)) return n;
|
||||
const original = byId.get(n.id);
|
||||
if (!original) return n;
|
||||
return {
|
||||
...n,
|
||||
position: original.position,
|
||||
parentId: original.parentId,
|
||||
zIndex: original.zIndex,
|
||||
data: { ...n.data, parentId: original.data.parentId },
|
||||
};
|
||||
}),
|
||||
});
|
||||
// Surface the partial failure — silent rollback would otherwise
|
||||
// leave the canvas in a state the user can't explain ("I dragged
|
||||
// 5 cards, 3 moved and 2 snapped back?"). Cap the name list so a
|
||||
// 50-node partial failure doesn't overflow the toast container.
|
||||
const NAMES_IN_TOAST = 3;
|
||||
const names = rolledBack
|
||||
.map((id) => byId.get(id)?.data.name)
|
||||
.filter((n): n is string => Boolean(n));
|
||||
const shown = names.slice(0, NAMES_IN_TOAST).join(", ");
|
||||
const overflow = names.length - NAMES_IN_TOAST;
|
||||
const listFragment = shown
|
||||
? overflow > 0
|
||||
? `: ${shown} and ${overflow} more`
|
||||
: `: ${shown}`
|
||||
: "";
|
||||
showToast(
|
||||
`Could not re-parent ${rolledBack.length} of ${plan.length} workspace${plan.length === 1 ? "" : "s"}${listFragment}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
bumpZOrder: (nodeId, direction) => {
|
||||
const { nodes } = get();
|
||||
const target = nodes.find((n) => n.id === nodeId);
|
||||
if (!target) return;
|
||||
// Siblings share parentId; re-rank them by their current zIndex (then
|
||||
// insertion order) so we can SWAP the target with its neighbour in
|
||||
// the bump direction rather than drifting zIndex up/down unbounded.
|
||||
// This keeps sibling zIndex values within `[baseDepth, baseDepth+N)`,
|
||||
// which is what findDropTarget's tiebreakers assume.
|
||||
const siblings = nodes
|
||||
.filter((n) => n.data.parentId === target.data.parentId)
|
||||
.slice()
|
||||
.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
|
||||
if (siblings.length < 2) return;
|
||||
const idx = siblings.findIndex((n) => n.id === nodeId);
|
||||
const neighbourIdx = idx + direction;
|
||||
if (neighbourIdx < 0 || neighbourIdx >= siblings.length) return;
|
||||
const neighbour = siblings[neighbourIdx];
|
||||
const targetZ = target.zIndex ?? 0;
|
||||
const neighbourZ = neighbour.zIndex ?? 0;
|
||||
// Ensure a visible swap even when both had identical zIndex (fresh
|
||||
// topology: every sibling starts at zIndex=depth). Nudge the
|
||||
// neighbour one step the other way so the pair stays adjacent.
|
||||
const resolvedTargetZ = targetZ === neighbourZ ? targetZ + direction : neighbourZ;
|
||||
const resolvedNeighbourZ = targetZ === neighbourZ ? targetZ : targetZ;
|
||||
set({
|
||||
nodes: nodes.map((n) => {
|
||||
if (n.id === nodeId) return { ...n, zIndex: resolvedTargetZ };
|
||||
if (n.id === neighbour.id) return { ...n, zIndex: resolvedNeighbourZ };
|
||||
return n;
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
isDescendant: (ancestorId, nodeId) => {
|
||||
const { nodes } = get();
|
||||
let current = nodes.find((n) => n.id === nodeId);
|
||||
@ -258,46 +588,136 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
|
||||
nestNode: async (draggedId, targetId) => {
|
||||
const { nodes, edges } = get();
|
||||
const currentParentId = nodes.find((n) => n.id === draggedId)?.data.parentId ?? null;
|
||||
|
||||
// No change needed
|
||||
const dragged = nodes.find((n) => n.id === draggedId);
|
||||
if (!dragged) return;
|
||||
const currentParentId = dragged.data.parentId;
|
||||
if (currentParentId === targetId) return;
|
||||
|
||||
// Optimistic update:
|
||||
// - Set parentId in data
|
||||
// - Hide child nodes (they render inside parent WorkspaceNode)
|
||||
// - Remove all edges involving the dragged node
|
||||
// Compute each ancestor's absolute position by walking up the
|
||||
// parentId chain. We need this to translate the dragged node's
|
||||
// `position` (relative to its current parent when nested) between
|
||||
// the old and new coordinate spaces so the card doesn't visually
|
||||
// jump on nest/unnest.
|
||||
const absOf = (id: string | null): { x: number; y: number } => {
|
||||
let sum = { x: 0, y: 0 };
|
||||
let cursor: string | null = id;
|
||||
while (cursor) {
|
||||
const n = nodes.find((nn) => nn.id === cursor);
|
||||
if (!n) break;
|
||||
sum = { x: sum.x + n.position.x, y: sum.y + n.position.y };
|
||||
cursor = n.data.parentId;
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
const oldParentAbs = absOf(currentParentId);
|
||||
const newParentAbs = absOf(targetId);
|
||||
const draggedAbs = {
|
||||
x: dragged.position.x + oldParentAbs.x,
|
||||
y: dragged.position.y + oldParentAbs.y,
|
||||
};
|
||||
const newRelative = {
|
||||
x: draggedAbs.x - newParentAbs.x,
|
||||
y: draggedAbs.y - newParentAbs.y,
|
||||
};
|
||||
|
||||
const newEdges = edges.filter(
|
||||
(e) => e.source !== draggedId && e.target !== draggedId
|
||||
(e) => e.source !== draggedId && e.target !== draggedId,
|
||||
);
|
||||
|
||||
// Depth walk so zIndex gets bumped correctly on nest/unnest
|
||||
// (children render above their new ancestor chain). `depthOf(null)`
|
||||
// returns 0; for any non-null cursor we count one hop per ancestor.
|
||||
const depthOf = (id: string | null | undefined): number => {
|
||||
let d = 0;
|
||||
let cursor: string | null | undefined = id;
|
||||
while (cursor) {
|
||||
const n = nodes.find((nn) => nn.id === cursor);
|
||||
if (!n) break;
|
||||
cursor = n.data.parentId;
|
||||
d += 1;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
const newOwnDepth = targetId ? depthOf(targetId) + 1 : 0;
|
||||
const oldOwnDepth = dragged.zIndex ?? depthOf(currentParentId) + (currentParentId ? 1 : 0);
|
||||
const depthDelta = newOwnDepth - oldOwnDepth;
|
||||
|
||||
// Collect every descendant of the dragged node so we can shift their
|
||||
// zIndex by the same depthDelta — otherwise grandchildren stay at
|
||||
// their old depth zIndex after the move and render below ancestors
|
||||
// they just joined. BFS to avoid stack surprises on deep hierarchies.
|
||||
const movedIds = new Set<string>([draggedId]);
|
||||
const bfsQueue = [draggedId];
|
||||
while (bfsQueue.length) {
|
||||
const head = bfsQueue.shift()!;
|
||||
for (const n of nodes) {
|
||||
if (n.data.parentId === head && !movedIds.has(n.id)) {
|
||||
movedIds.add(n.id);
|
||||
bfsQueue.push(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When a child leaves its parent, clear the parent's explicit
|
||||
// width/height. growParentsToFitChildren is grow-only so it can't
|
||||
// shrink on its own; without this, a parent that auto-grew to
|
||||
// contain the dragged child stays at that size after un-nest,
|
||||
// leaving a large empty frame. React Flow then measures the new
|
||||
// size from the card's own min-width/min-height CSS.
|
||||
const shrinkOldParent = !!currentParentId && targetId !== currentParentId;
|
||||
|
||||
set({
|
||||
nodes: nodes.map((n) =>
|
||||
n.id === draggedId
|
||||
? {
|
||||
...n,
|
||||
hidden: !!targetId, // Hide if becoming a child, show if un-nesting
|
||||
data: { ...n.data, parentId: targetId },
|
||||
}
|
||||
: n
|
||||
),
|
||||
nodes: nodes.map((n) => {
|
||||
if (n.id === draggedId) {
|
||||
return {
|
||||
...n,
|
||||
position: newRelative,
|
||||
parentId: targetId ?? undefined,
|
||||
zIndex: newOwnDepth,
|
||||
data: { ...n.data, parentId: targetId },
|
||||
};
|
||||
}
|
||||
if (shrinkOldParent && n.id === currentParentId) {
|
||||
const { width: _w, height: _h, ...rest } = n;
|
||||
void _w; void _h;
|
||||
return rest as typeof n;
|
||||
}
|
||||
if (movedIds.has(n.id) && depthDelta !== 0) {
|
||||
return { ...n, zIndex: (n.zIndex ?? 0) + depthDelta };
|
||||
}
|
||||
return n;
|
||||
}),
|
||||
edges: newEdges,
|
||||
});
|
||||
// React Flow requires parents before children in the array. Without
|
||||
// this re-sort a newly-nested child can end up ahead of its new
|
||||
// parent, which makes RF log "Parent node not found" and render the
|
||||
// child at canvas-absolute coords (far outside the parent, which
|
||||
// is the flash-bug the user just flagged).
|
||||
set({ nodes: sortParentsBeforeChildren(get().nodes) });
|
||||
|
||||
// Persist to API
|
||||
try {
|
||||
await api.patch(`/workspaces/${draggedId}`, { parent_id: targetId });
|
||||
// One round-trip per nest: the /workspaces/:id PATCH handler
|
||||
// accepts parent_id + x + y in a single body. The absolute x/y
|
||||
// is what the DB stores as canonical (matches savePosition
|
||||
// elsewhere), so reload renders the same place regardless of
|
||||
// which parent the child was under at save time.
|
||||
await api.patch(`/workspaces/${draggedId}`, {
|
||||
parent_id: targetId,
|
||||
x: draggedAbs.x,
|
||||
y: draggedAbs.y,
|
||||
});
|
||||
} catch {
|
||||
// Revert on failure
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === draggedId
|
||||
? {
|
||||
...n,
|
||||
hidden: !!currentParentId,
|
||||
position: dragged.position,
|
||||
parentId: currentParentId ?? undefined,
|
||||
data: { ...n.data, parentId: currentParentId },
|
||||
}
|
||||
: n
|
||||
: n,
|
||||
),
|
||||
edges,
|
||||
});
|
||||
@ -325,7 +745,10 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
|
||||
removeNode: (id) => {
|
||||
const { nodes, edges, selectedNodeId } = get();
|
||||
// Re-parent children to the deleted node's parent (or root)
|
||||
// Re-parent children to the deleted node's parent (or root).
|
||||
// Children are first-class RF nodes now — we just re-point their
|
||||
// parentId (both RF's native field and our data mirror). No hidden
|
||||
// flag is toggled because cards are always visible.
|
||||
const deletedNode = nodes.find((n) => n.id === id);
|
||||
const parentOfDeleted = deletedNode?.data.parentId ?? null;
|
||||
set({
|
||||
@ -335,7 +758,7 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
n.data.parentId === id
|
||||
? {
|
||||
...n,
|
||||
hidden: !!parentOfDeleted,
|
||||
parentId: parentOfDeleted ?? undefined,
|
||||
data: { ...n.data, parentId: parentOfDeleted },
|
||||
}
|
||||
: n
|
||||
@ -359,11 +782,118 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
},
|
||||
|
||||
onNodesChange: (changes) => {
|
||||
const next = applyNodeChanges(changes, get().nodes);
|
||||
// Parent auto-grow is intentionally conservative. Running
|
||||
// growParentsToFitChildren on every change (including the dozens of
|
||||
// position updates emitted during a single drag) caused the
|
||||
// "edge-chase" artifact tldraw documented — as the parent grows in
|
||||
// response to the child near its edge, the child's relative
|
||||
// position becomes valid again and the grow stops mid-drag, only to
|
||||
// resume on the next tick. Commit-on-release: only run grow when a
|
||||
// change set contains a `dimensions` change (NodeResizer commit),
|
||||
// not on pure `position` changes. Drag-stop grow is handled
|
||||
// explicitly in Canvas.onNodeDragStop via growOnce().
|
||||
const hasDimensionChange = changes.some((c) => c.type === "dimensions");
|
||||
set({ nodes: hasDimensionChange ? growParentsToFitChildren(next) : next });
|
||||
},
|
||||
|
||||
growParentsToFitChildren: () => {
|
||||
set({ nodes: growParentsToFitChildren(get().nodes) });
|
||||
},
|
||||
|
||||
setCollapsed: (parentId, collapsed) => {
|
||||
const { nodes } = get();
|
||||
// Step 1 — apply the new collapsed flag on the target.
|
||||
const updatedCollapsed = new Map<string, boolean>();
|
||||
for (const n of nodes) {
|
||||
updatedCollapsed.set(
|
||||
n.id,
|
||||
n.id === parentId ? collapsed : !!n.data.collapsed,
|
||||
);
|
||||
}
|
||||
// Step 2 — index children once so the visibility pass is O(n), not
|
||||
// O(n·d). Walk roots downward, inheriting `hiddenBecauseAncestor`
|
||||
// so a node is hidden iff ANY ancestor in the chain is collapsed.
|
||||
// This matches canvas-topology.buildNodesAndEdges so setCollapsed
|
||||
// and hydrate produce identical node.hidden flags — no drift when
|
||||
// the server pushes a fresh snapshot mid-session.
|
||||
const childrenByParent = new Map<string | null, string[]>();
|
||||
for (const n of nodes) {
|
||||
const p = n.data.parentId ?? null;
|
||||
const arr = childrenByParent.get(p) ?? [];
|
||||
arr.push(n.id);
|
||||
childrenByParent.set(p, arr);
|
||||
}
|
||||
const hiddenById = new Map<string, boolean>();
|
||||
const stack: Array<{ id: string; hidden: boolean }> = (
|
||||
childrenByParent.get(null) ?? []
|
||||
).map((id) => ({ id, hidden: false }));
|
||||
while (stack.length) {
|
||||
const { id, hidden } = stack.pop()!;
|
||||
hiddenById.set(id, hidden);
|
||||
const isCollapsed = updatedCollapsed.get(id) ?? false;
|
||||
for (const childId of childrenByParent.get(id) ?? []) {
|
||||
stack.push({ id: childId, hidden: hidden || isCollapsed });
|
||||
}
|
||||
}
|
||||
set({
|
||||
nodes: applyNodeChanges(changes, get().nodes),
|
||||
nodes: nodes.map((n) => {
|
||||
const isTarget = n.id === parentId;
|
||||
const nextHidden = hiddenById.get(n.id) ?? false;
|
||||
if (!isTarget && n.hidden === nextHidden) return n;
|
||||
return {
|
||||
...n,
|
||||
hidden: nextHidden,
|
||||
data: isTarget ? { ...n.data, collapsed } : n.data,
|
||||
};
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
arrangeChildren: (parentId) => {
|
||||
const { nodes } = get();
|
||||
const kids = nodes
|
||||
.filter((n) => n.parentId === parentId)
|
||||
.sort((a, b) => (a.data.name || "").localeCompare(b.data.name || ""));
|
||||
if (kids.length === 0) return;
|
||||
const slotByKid = new Map<string, { x: number; y: number }>();
|
||||
kids.forEach((k, i) => slotByKid.set(k.id, defaultChildSlot(i)));
|
||||
|
||||
// Absolute position of the parent, walking the full ancestor chain.
|
||||
// Required for a correct PATCH payload when the parent itself is
|
||||
// nested — `parent.position` is RELATIVE to its own parent, so a
|
||||
// naive `slot + parent.position` would store parent-local coords
|
||||
// as if they were absolute and corrupt the workspace on reload.
|
||||
const absOf = (id: string | null | undefined): { x: number; y: number } => {
|
||||
let sum = { x: 0, y: 0 };
|
||||
let cursor: string | null | undefined = id;
|
||||
while (cursor) {
|
||||
const n = nodes.find((nn) => nn.id === cursor);
|
||||
if (!n) break;
|
||||
sum = { x: sum.x + n.position.x, y: sum.y + n.position.y };
|
||||
cursor = n.data.parentId;
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
const parentAbs = absOf(parentId);
|
||||
|
||||
set({
|
||||
nodes: nodes.map((n) => {
|
||||
const slot = slotByKid.get(n.id);
|
||||
return slot ? { ...n, position: slot } : n;
|
||||
}),
|
||||
});
|
||||
|
||||
for (const k of kids) {
|
||||
const slot = slotByKid.get(k.id)!;
|
||||
const absX = slot.x + parentAbs.x;
|
||||
const absY = slot.y + parentAbs.y;
|
||||
api.patch(`/workspaces/${k.id}`, { x: absX, y: absY }).catch((e) => {
|
||||
console.warn(`arrangeChildren: failed to persist position for ${k.id}`, e);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
savePosition: async (nodeId: string, x: number, y: number) => {
|
||||
try {
|
||||
await api.patch(`/workspaces/${nodeId}`, { x, y });
|
||||
|
||||
@ -330,6 +330,15 @@ func validateDiscoveryCaller(ctx context.Context, c *gin.Context, workspaceID st
|
||||
if !hasLive {
|
||||
return nil // legacy / pre-upgrade
|
||||
}
|
||||
// Tier-1b dev-mode hatch — same escape hatch AdminAuth and
|
||||
// WorkspaceAuth apply on a local Docker setup. Without this, the
|
||||
// canvas Details tab can never load peers for a workspace that has
|
||||
// registered its live token, producing the 401 the user sees.
|
||||
// Gated by MOLECULE_ENV=development + empty ADMIN_TOKEN, so SaaS
|
||||
// production stays strict.
|
||||
if middleware.IsDevModeFailOpen() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try session cookie auth first (SaaS canvas path).
|
||||
// verifiedCPSession returns (valid, presented):
|
||||
|
||||
@ -618,3 +618,109 @@ func TestDiscoverHostPeer_Smoke_Success(t *testing.T) {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Peers auth — dev-mode fail-open gate ====================
|
||||
//
|
||||
// validateDiscoveryCaller applies a Tier-1b dev-mode hatch so the canvas
|
||||
// user session (which holds no workspace-scoped bearer) can still load
|
||||
// the Details → PEERS list on a local Docker setup. The gate must pass
|
||||
// ONLY when MOLECULE_ENV is development AND ADMIN_TOKEN is empty.
|
||||
// These tests pin that contract against accidental polarity flips.
|
||||
|
||||
// peersAuthFixtureHasLiveToken seeds the mock rows required for the
|
||||
// Peers handler to reach the auth branch: HasAnyLiveToken → true (a
|
||||
// non-zero count so validateDiscoveryCaller has to make the dev-mode
|
||||
// decision instead of grandfathering the request).
|
||||
func peersAuthFixtureHasLiveToken(mock sqlmock.Sqlmock, workspaceID string) {
|
||||
// HasAnyLiveToken issues `SELECT COUNT(*) FROM workspace_auth_tokens ...`
|
||||
mock.ExpectQuery("SELECT COUNT.+workspace_auth_tokens").
|
||||
WithArgs(workspaceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
}
|
||||
|
||||
func TestPeers_DevModeFailOpen_AllowsBearerlessRequest(t *testing.T) {
|
||||
// Dev mode: MOLECULE_ENV=development AND ADMIN_TOKEN empty. Canvas
|
||||
// sends no bearer token; validateDiscoveryCaller must return nil
|
||||
// (allow) and the handler must proceed to return the peer list.
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewDiscoveryHandler()
|
||||
|
||||
peersAuthFixtureHasLiveToken(mock, "ws-dev")
|
||||
|
||||
// Root workspace → children+parent queries still fire but the
|
||||
// parent_id lookup comes first.
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-dev").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil))
|
||||
peerCols := []string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}
|
||||
mock.ExpectQuery("SELECT w.id.+WHERE w.parent_id IS NULL AND w.id").
|
||||
WithArgs("ws-dev").
|
||||
WillReturnRows(sqlmock.NewRows(peerCols))
|
||||
mock.ExpectQuery("SELECT w.id.+WHERE w.parent_id = \\$1 AND w.status").
|
||||
WithArgs("ws-dev").
|
||||
WillReturnRows(sqlmock.NewRows(peerCols))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-dev"}}
|
||||
c.Request = httptest.NewRequest("GET", "/registry/ws-dev/peers", nil)
|
||||
|
||||
handler.Peers(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 under dev-mode hatch, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeers_DevModeFailOpen_ClosedWhenAdminTokenSet(t *testing.T) {
|
||||
// An operator with ADMIN_TOKEN set has explicitly opted into #684
|
||||
// closure; dev-mode hatch must NOT open even when MOLECULE_ENV is
|
||||
// "development". This is the SaaS guarantee.
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "seven-admin-token")
|
||||
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewDiscoveryHandler()
|
||||
|
||||
peersAuthFixtureHasLiveToken(mock, "ws-prod")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-prod"}}
|
||||
c.Request = httptest.NewRequest("GET", "/registry/ws-prod/peers", nil)
|
||||
|
||||
handler.Peers(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 with ADMIN_TOKEN set, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeers_DevModeFailOpen_ClosedInProduction(t *testing.T) {
|
||||
// Production MOLECULE_ENV — hatch must stay closed regardless of
|
||||
// ADMIN_TOKEN state. SaaS production rejects the bearerless call.
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewDiscoveryHandler()
|
||||
|
||||
peersAuthFixtureHasLiveToken(mock, "ws-prod")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-prod"}}
|
||||
c.Request = httptest.NewRequest("GET", "/registry/ws-prod/peers", nil)
|
||||
|
||||
handler.Peers(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 in production, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,6 +183,40 @@ func TestWorkspaceUpdate_NameOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- workspace.go: Update with collapsed flag ----------
|
||||
|
||||
func TestWorkspaceUpdate_Collapsed(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// Canvas "collapse team" flip — the handler must run the UPDATE
|
||||
// to persist the flag, otherwise the UI state resets on reload.
|
||||
mock.ExpectQuery("SELECT EXISTS.*workspaces WHERE id").
|
||||
WithArgs("dddddddd-0005-0000-0000-000000000000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec("UPDATE workspaces SET collapsed").
|
||||
WithArgs("dddddddd-0005-0000-0000-000000000000", true).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "dddddddd-0005-0000-0000-000000000000"}}
|
||||
body := `{"collapsed":true}`
|
||||
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-collapse", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- workspace.go: List with actual data ----------
|
||||
|
||||
func TestWorkspaceList_WithData(t *testing.T) {
|
||||
|
||||
@ -88,11 +88,19 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert workspace
|
||||
// Org-template imports can drop dozens of nested workspaces onto the
|
||||
// canvas at once. Letting them render expanded by default sprays
|
||||
// child cards across the viewport (sibling workspaces spill below
|
||||
// the parent before the user can orient themselves). Default every
|
||||
// parent in the imported tree to collapsed — the parent card shows
|
||||
// only its header + "N sub" badge until the user double-clicks to
|
||||
// expand it. Leaf workspaces stay expanded (nothing to hide).
|
||||
initialCollapsed := len(ws.Children) > 0
|
||||
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess)
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, collapsed)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess, initialCollapsed)
|
||||
if err != nil {
|
||||
log.Printf("Org import: failed to create %s: %v", ws.Name, err)
|
||||
return fmt.Errorf("failed to create %s: %w", ws.Name, err)
|
||||
|
||||
@ -188,6 +188,14 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
log.Printf("Update parent_id error for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
if collapsed, ok := body["collapsed"]; ok {
|
||||
// `collapsed` is the canvas UI-only flag that hides descendants
|
||||
// in the tree view (WorkspaceNode renders the parent as header-
|
||||
// only). Persisting it here so the state survives reload.
|
||||
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET collapsed = $2, updated_at = now() WHERE id = $1`, id, collapsed); err != nil {
|
||||
log.Printf("Update collapsed error for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
if runtime, ok := body["runtime"]; ok {
|
||||
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET runtime = $2, updated_at = now() WHERE id = $1`, id, runtime); err != nil {
|
||||
log.Printf("Update runtime error for %s: %v", id, err)
|
||||
|
||||
@ -54,3 +54,12 @@ func isDevModeFailOpen() bool {
|
||||
_, ok := devModeEnvValues[env]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsDevModeFailOpen exposes isDevModeFailOpen to packages outside the
|
||||
// middleware module (handlers, discovery, etc.) so they can apply the
|
||||
// same Tier-1b escape hatch their sibling AdminAuth / WorkspaceAuth
|
||||
// already do. Keep every call site audit-tagged so security review can
|
||||
// grep them.
|
||||
func IsDevModeFailOpen() bool {
|
||||
return isDevModeFailOpen()
|
||||
}
|
||||
|
||||
@ -57,6 +57,19 @@ func NewRateLimiter(rate int, interval time.Duration, ctx context.Context) *Rate
|
||||
// Middleware returns a Gin middleware that rate limits by client IP.
|
||||
func (rl *RateLimiter) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Tier-1b dev-mode hatch — same gate as AdminAuth / WorkspaceAuth /
|
||||
// discovery. On a local single-user Docker setup the 600-req/min
|
||||
// bucket fills fast: a 15-workspace canvas + activity polling +
|
||||
// approvals polling + A2A overlay + initial hydration all share
|
||||
// one IP bucket, so a minute of active use can trip 429 and blank
|
||||
// the page. Gated by MOLECULE_ENV=development + empty ADMIN_TOKEN
|
||||
// so SaaS production keeps the bucket.
|
||||
if isDevModeFailOpen() {
|
||||
c.Header("X-RateLimit-Limit", "unlimited")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
ip := c.ClientIP()
|
||||
|
||||
rl.mu.Lock()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user