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:
Hongming Wang 2026-04-23 21:08:52 -07:00 committed by GitHub
commit 6745a61ebf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2291 additions and 1040 deletions

View File

@ -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>
</>
);

View File

@ -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 }]),

View File

@ -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}

View File

@ -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 [];

View File

@ -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();
});
});

View File

@ -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");
});
});

View File

@ -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"');

View 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>
</>
);
}

View 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,
),
});
}

View 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 };
}

View 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,
};
}

View 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);
}, []);
}

View File

@ -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]);

View File

@ -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 */ }
};

View File

@ -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 {

View File

@ -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(() => {

View File

@ -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

View File

@ -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", () => {

View File

@ -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 });
});
});

View File

@ -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);
});
});

View File

@ -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

View File

@ -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 absrel 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 };

View File

@ -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 });

View File

@ -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):

View File

@ -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())
}
}

View File

@ -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) {

View File

@ -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)

View File

@ -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)

View File

@ -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()
}

View File

@ -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()