diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 3e37f7b..127629f 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -36,6 +36,13 @@ import {
const NODE_TYPES = { custom: CustomNode };
+const GROUP_PADDING_X = 24;
+const GROUP_PADDING_Y = 24;
+const GROUP_HEADER_HEIGHT = 36;
+const GROUP_WORKSPACE_INSET = 12;
+const GROUP_MIN_WIDTH = 260;
+const GROUP_MIN_HEIGHT = 180;
+
// ── Handle ID helpers ─────────────────────────────────────────────────
function getHandleType(handleId) {
@@ -50,6 +57,228 @@ function getOutputSlot(handleId) {
return parseInt(handleId.split('::')[1], 10);
}
+function encodeProxyHandleRef(handleId) {
+ return encodeURIComponent(String(handleId || ''));
+}
+
+function decodeProxyHandleRef(encoded) {
+ try {
+ return decodeURIComponent(String(encoded || ''));
+ } catch {
+ return String(encoded || '');
+ }
+}
+
+function parseGroupProxyHandle(handleId) {
+ const text = String(handleId || '');
+ if (!text.startsWith('group-proxy::')) return null;
+ const parts = text.split('::');
+ if (parts.length < 5) return null;
+ return {
+ direction: parts[1],
+ nodeId: parts[2],
+ type: parts[3],
+ realHandle: decodeProxyHandleRef(parts.slice(4).join('::')),
+ };
+}
+
+function getConnectionHandleType(handleId) {
+ const proxy = parseGroupProxyHandle(handleId);
+ return proxy?.type || getHandleType(handleId);
+}
+
+function getNodeDimension(node, axis) {
+ if (axis === 'width') return node.measured?.width || node.width || node.style?.width || 200;
+ return node.measured?.height || node.height || node.style?.height || 120;
+}
+
+function getNodeAbsolutePosition(node, nodeMap) {
+ if (node?.positionAbsolute) {
+ return {
+ x: Number(node.positionAbsolute.x) || 0,
+ y: Number(node.positionAbsolute.y) || 0,
+ };
+ }
+ const local = {
+ x: Number(node?.position?.x) || 0,
+ y: Number(node?.position?.y) || 0,
+ };
+ if (!node?.parentId) return local;
+ const parent = nodeMap.get(String(node.parentId));
+ if (!parent) return local;
+ const parentPos = getNodeAbsolutePosition(parent, nodeMap);
+ return { x: parentPos.x + local.x, y: parentPos.y + local.y };
+}
+
+function collectGroupDescendantIds(nodes, groupId) {
+ const allNodes = Array.isArray(nodes) ? nodes : [];
+ const result = new Set();
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const node of allNodes) {
+ const parentId = node?.parentId ? String(node.parentId) : null;
+ const nodeId = String(node?.id);
+ if (!parentId) continue;
+ if ((parentId === String(groupId) || result.has(parentId)) && !result.has(nodeId)) {
+ result.add(nodeId);
+ changed = true;
+ }
+ }
+ }
+ return result;
+}
+
+function getGroupMembers(nodes, groupId) {
+ const descendants = collectGroupDescendantIds(nodes, groupId);
+ return Array.from(descendants);
+}
+
+function getGroupDisplayBounds(nodes, selectedIds) {
+ const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node]));
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ for (const id of selectedIds) {
+ const node = nodeMap.get(String(id));
+ if (!node) continue;
+ const pos = getNodeAbsolutePosition(node, nodeMap);
+ const width = Number(getNodeDimension(node, 'width')) || 200;
+ const height = Number(getNodeDimension(node, 'height')) || 120;
+ minX = Math.min(minX, pos.x);
+ minY = Math.min(minY, pos.y);
+ maxX = Math.max(maxX, pos.x + width);
+ maxY = Math.max(maxY, pos.y + height);
+ }
+
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
+ return null;
+ }
+
+ return { minX, minY, maxX, maxY };
+}
+
+function getGroupWorkspaceBounds(groupNode, nodeMap) {
+ const pos = getNodeAbsolutePosition(groupNode, nodeMap);
+ const width = Number(getNodeDimension(groupNode, 'width')) || 200;
+ const height = Number(getNodeDimension(groupNode, 'height')) || 120;
+ return {
+ left: pos.x + GROUP_WORKSPACE_INSET,
+ top: pos.y + GROUP_HEADER_HEIGHT + GROUP_WORKSPACE_INSET,
+ right: pos.x + width - GROUP_WORKSPACE_INSET,
+ bottom: pos.y + height - GROUP_WORKSPACE_INSET,
+ };
+}
+
+function getNodeCenter(node, nodeMap) {
+ const pos = getNodeAbsolutePosition(node, nodeMap);
+ const width = Number(getNodeDimension(node, 'width')) || 200;
+ const height = Number(getNodeDimension(node, 'height')) || 120;
+ return {
+ x: pos.x + width / 2,
+ y: pos.y + height / 2,
+ };
+}
+
+function rectContainsPoint(rect, point) {
+ return point.x >= rect.left
+ && point.x <= rect.right
+ && point.y >= rect.top
+ && point.y <= rect.bottom;
+}
+
+function findExpandedGroupDropTarget(nodes, draggedNodeIds, anchorNodeId) {
+ const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node]));
+ const anchorNode = nodeMap.get(String(anchorNodeId));
+ if (!anchorNode) return null;
+
+ const draggedIdSet = new Set((draggedNodeIds || []).map((id) => String(id)));
+ const anchorCenter = getNodeCenter(anchorNode, nodeMap);
+
+ return (nodes || [])
+ .filter((node) => (
+ node?.data?.className === 'Group'
+ && !node?.data?.collapsed
+ && !draggedIdSet.has(String(node.id))
+ ))
+ .map((node) => {
+ const rect = getGroupWorkspaceBounds(node, nodeMap);
+ return {
+ node,
+ rect,
+ area: Math.max(1, rect.right - rect.left) * Math.max(1, rect.bottom - rect.top),
+ };
+ })
+ .filter(({ rect }) => rectContainsPoint(rect, anchorCenter))
+ .sort((a, b) => a.area - b.area)[0]?.node || null;
+}
+
+function getInputLabelForNode(node, inputName) {
+ const inputs = {
+ ...(node?.data?.definition?.input?.required || {}),
+ ...(node?.data?.definition?.input?.optional || {}),
+ };
+ const spec = inputs[inputName];
+ if (!spec) return inputName;
+ const [, opts] = Array.isArray(spec) ? spec : [spec, {}];
+ return opts?.label || inputName;
+}
+
+function getOutputLabelForNode(node, slot, handleId) {
+ const outputNames = node?.data?.definition?.output_name || [];
+ const outputTypes = node?.data?.definition?.output || [];
+ if (Number.isInteger(slot) && outputNames[slot]) return outputNames[slot];
+ const proxy = parseGroupProxyHandle(handleId);
+ return proxy?.realHandle ? getOutputLabelForNode(node, getOutputSlot(proxy.realHandle), proxy.realHandle) : outputTypes[slot] || 'output';
+}
+
+function buildGroupProxyData(groupId, nodes, edges) {
+ const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node]));
+ const memberIds = new Set(getGroupMembers(nodes, groupId));
+ const proxyInputs = [];
+ const proxyOutputs = [];
+ const seenInputs = new Set();
+ const seenOutputs = new Set();
+
+ for (const edge of edges || []) {
+ const original = edge?.data?.groupProxyOriginal || {};
+ const sourceId = String(original.source || edge.source);
+ const targetId = String(original.target || edge.target);
+ const sourceHandle = original.sourceHandle || edge.sourceHandle;
+ const targetHandle = original.targetHandle || edge.targetHandle;
+ const sourceInside = memberIds.has(sourceId);
+ const targetInside = memberIds.has(targetId);
+
+ if (!sourceInside && targetInside) {
+ const key = `${targetId}::${targetHandle}`;
+ if (seenInputs.has(key)) continue;
+ seenInputs.add(key);
+ proxyInputs.push({
+ key,
+ type: getHandleType(targetHandle),
+ label: getInputLabelForNode(nodeMap.get(targetId), getInputName(targetHandle)),
+ handleId: `group-proxy::in::${targetId}::${getHandleType(targetHandle)}::${encodeProxyHandleRef(targetHandle)}`,
+ });
+ }
+
+ if (sourceInside && !targetInside) {
+ const key = `${sourceId}::${sourceHandle}`;
+ if (seenOutputs.has(key)) continue;
+ seenOutputs.add(key);
+ proxyOutputs.push({
+ key,
+ type: getHandleType(sourceHandle),
+ label: getOutputLabelForNode(nodeMap.get(sourceId), getOutputSlot(sourceHandle), sourceHandle),
+ handleId: `group-proxy::out::${sourceId}::${getHandleType(sourceHandle)}::${encodeProxyHandleRef(sourceHandle)}`,
+ });
+ }
+ }
+
+ return { proxyInputs, proxyOutputs, childCount: memberIds.size };
+}
+
function sameStringArray(a = [], b = []) {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
@@ -243,7 +472,7 @@ async function captureViewportBlob(viewportEl, options) {
// ── Context menu component ────────────────────────────────────────────
-function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirection }) {
+function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirection, selectedNodeCount = 0, onCreateGroup = null }) {
const [openCat, setOpenCat] = useState(null);
const [search, setSearch] = useState('');
const menuRef = useRef(null);
@@ -396,6 +625,15 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti
/>
+ {!filterType && selectedNodeCount > 1 && typeof onCreateGroup === 'function' && (
+
{ onCreateGroup(); onClose(); }}
+ >
+ create group
+
+ )}
+
{searchResults ? (
{searchResults.length === 0 ? (
@@ -474,6 +712,7 @@ function Flow() {
const lastPastedClipboardTextRef = useRef('');
const pasteRepeatCountRef = useRef(0);
const duplicateDragRef = useRef(null);
+ const activeDragNodeIdRef = useRef(null);
const reactFlow = useReactFlow();
// ── WebSocket ───────────────────────────────────────────────────────
@@ -484,6 +723,286 @@ function Flow() {
));
}, [setNodes]);
+ const refreshGroupNode = useCallback((groupId, explicitNodes = null, explicitEdges = null) => {
+ const currentNodes = explicitNodes || reactFlow.getNodes();
+ const currentEdges = explicitEdges || reactFlow.getEdges();
+ const groupNode = currentNodes.find((node) => node.id === groupId && node.data?.className === 'Group');
+ if (!groupNode) return;
+
+ const { proxyInputs, proxyOutputs, childCount } = buildGroupProxyData(groupId, currentNodes, currentEdges);
+ setNodes((prev) => prev.map((node) => (
+ node.id !== groupId
+ ? node
+ : {
+ ...node,
+ className: 'group-shell',
+ data: {
+ ...node.data,
+ proxyInputs,
+ proxyOutputs,
+ childCount,
+ },
+ }
+ )));
+ reactFlow.updateNodeInternals(groupId);
+ }, [reactFlow, setNodes]);
+
+ const toggleGroupCollapse = useCallback((groupId) => {
+ const currentNodes = reactFlow.getNodes();
+ const currentEdges = reactFlow.getEdges();
+ const groupNode = currentNodes.find((node) => node.id === groupId && node.data?.className === 'Group');
+ if (!groupNode) return;
+
+ const memberIds = new Set(getGroupMembers(currentNodes, groupId));
+ const collapsed = !groupNode.data?.collapsed;
+ const proxyData = buildGroupProxyData(groupId, currentNodes, currentEdges);
+
+ const nextNodes = currentNodes.map((node) => {
+ if (memberIds.has(String(node.id))) {
+ return { ...node, hidden: collapsed };
+ }
+ if (node.id !== groupId) return node;
+ const expandedSize = groupNode.data?.expandedSize || {
+ width: Number(groupNode.style?.width) || 320,
+ height: Number(groupNode.style?.height) || 240,
+ };
+ const collapsedHeight = Math.max(74, 38 + Math.max(proxyData.proxyInputs.length, proxyData.proxyOutputs.length, 1) * 24 + 26);
+ return {
+ ...node,
+ style: collapsed
+ ? { ...(node.style || {}), width: 260, height: collapsedHeight }
+ : { ...(node.style || {}), width: expandedSize.width, height: expandedSize.height },
+ data: {
+ ...node.data,
+ collapsed,
+ expandedSize,
+ proxyInputs: proxyData.proxyInputs,
+ proxyOutputs: proxyData.proxyOutputs,
+ childCount: proxyData.childCount,
+ },
+ };
+ });
+
+ const nextEdges = currentEdges.map((edge) => {
+ if (collapsed) {
+ if (edge.data?.groupProxyOwner === groupId || edge.data?.groupInternalHiddenBy === groupId) {
+ return edge;
+ }
+ const sourceInside = memberIds.has(String(edge.source));
+ const targetInside = memberIds.has(String(edge.target));
+ if (sourceInside && targetInside) {
+ return {
+ ...edge,
+ hidden: true,
+ data: { ...(edge.data || {}), groupInternalHiddenBy: groupId },
+ };
+ }
+ if (!sourceInside && targetInside) {
+ return {
+ ...edge,
+ target: groupId,
+ targetHandle: `group-proxy::in::${edge.target}::${getHandleType(edge.targetHandle)}::${encodeProxyHandleRef(edge.targetHandle)}`,
+ data: {
+ ...(edge.data || {}),
+ groupProxyOwner: groupId,
+ groupProxyOriginal: {
+ target: edge.target,
+ targetHandle: edge.targetHandle,
+ },
+ },
+ };
+ }
+ if (sourceInside && !targetInside) {
+ return {
+ ...edge,
+ source: groupId,
+ sourceHandle: `group-proxy::out::${edge.source}::${getHandleType(edge.sourceHandle)}::${encodeProxyHandleRef(edge.sourceHandle)}`,
+ data: {
+ ...(edge.data || {}),
+ groupProxyOwner: groupId,
+ groupProxyOriginal: {
+ source: edge.source,
+ sourceHandle: edge.sourceHandle,
+ },
+ },
+ };
+ }
+ return edge;
+ }
+
+ if (edge.data?.groupInternalHiddenBy === groupId) {
+ const nextData = { ...(edge.data || {}) };
+ delete nextData.groupInternalHiddenBy;
+ return {
+ ...edge,
+ hidden: false,
+ data: Object.keys(nextData).length > 0 ? nextData : undefined,
+ };
+ }
+ if (edge.data?.groupProxyOwner === groupId) {
+ const nextData = { ...(edge.data || {}) };
+ const original = nextData.groupProxyOriginal || {};
+ delete nextData.groupProxyOwner;
+ delete nextData.groupProxyOriginal;
+ return {
+ ...edge,
+ source: original.source || edge.source,
+ sourceHandle: original.sourceHandle || edge.sourceHandle,
+ target: original.target || edge.target,
+ targetHandle: original.targetHandle || edge.targetHandle,
+ data: Object.keys(nextData).length > 0 ? nextData : undefined,
+ };
+ }
+ return edge;
+ });
+
+ setNodes(nextNodes);
+ setEdges(nextEdges);
+ setTimeout(() => refreshGroupNode(groupId, nextNodes, nextEdges), 0);
+ }, [reactFlow, refreshGroupNode, setEdges, setNodes]);
+
+ const ungroupGroup = useCallback((groupId) => {
+ const currentNodes = reactFlow.getNodes();
+ const currentEdges = reactFlow.getEdges();
+ const nodeMap = new Map(currentNodes.map((node) => [String(node.id), node]));
+ const groupNode = nodeMap.get(String(groupId));
+ if (!groupNode || groupNode.data?.className !== 'Group') return;
+
+ const memberIds = new Set(getGroupMembers(currentNodes, groupId));
+ const groupSelected = !!groupNode.selected;
+
+ const nextNodes = currentNodes
+ .filter((node) => String(node.id) !== String(groupId))
+ .map((node) => {
+ if (!memberIds.has(String(node.id))) return node;
+ const absolute = getNodeAbsolutePosition(node, nodeMap);
+ return {
+ ...node,
+ parentId: undefined,
+ extent: undefined,
+ hidden: false,
+ selected: groupSelected,
+ position: absolute,
+ };
+ });
+
+ const nextEdges = currentEdges
+ .map((edge) => {
+ if (edge.data?.groupInternalHiddenBy === groupId) {
+ const nextData = { ...(edge.data || {}) };
+ delete nextData.groupInternalHiddenBy;
+ return {
+ ...edge,
+ hidden: false,
+ data: Object.keys(nextData).length > 0 ? nextData : undefined,
+ };
+ }
+ if (edge.data?.groupProxyOwner === groupId) {
+ const nextData = { ...(edge.data || {}) };
+ const original = nextData.groupProxyOriginal || {};
+ delete nextData.groupProxyOwner;
+ delete nextData.groupProxyOriginal;
+ return {
+ ...edge,
+ source: original.source || edge.source,
+ sourceHandle: original.sourceHandle || edge.sourceHandle,
+ target: original.target || edge.target,
+ targetHandle: original.targetHandle || edge.targetHandle,
+ hidden: false,
+ data: Object.keys(nextData).length > 0 ? nextData : undefined,
+ };
+ }
+ return edge;
+ })
+ .filter((edge) => String(edge.source) !== String(groupId) && String(edge.target) !== String(groupId));
+
+ setNodes(nextNodes);
+ setEdges(nextEdges);
+ setTimeout(() => {
+ reactFlow.getNodes()
+ .filter((node) => node.data?.className === 'Group')
+ .forEach((node) => refreshGroupNode(node.id, nextNodes, nextEdges));
+ }, 0);
+ }, [reactFlow, refreshGroupNode, setEdges, setNodes]);
+
+ const createGroupFromSelection = useCallback(() => {
+ const currentNodes = reactFlow.getNodes();
+ const selectedNodes = currentNodes.filter((node) => node.selected && node.data?.className !== 'Group');
+ if (selectedNodes.length < 2) return;
+
+ const selectedIds = selectedNodes.map((node) => String(node.id));
+ const bounds = getGroupDisplayBounds(currentNodes, selectedIds);
+ if (!bounds) return;
+
+ const groupId = String(nextIdRef.current++);
+ const groupPosition = {
+ x: bounds.minX - GROUP_PADDING_X,
+ y: bounds.minY - (GROUP_HEADER_HEIGHT + GROUP_PADDING_Y),
+ };
+ const groupWidth = Math.max(
+ GROUP_MIN_WIDTH,
+ Math.round(bounds.maxX - bounds.minX + GROUP_PADDING_X * 2),
+ );
+ const groupHeight = Math.max(
+ GROUP_MIN_HEIGHT,
+ Math.round(bounds.maxY - bounds.minY + GROUP_HEADER_HEIGHT + GROUP_PADDING_Y * 2),
+ );
+
+ const groupNode = {
+ id: groupId,
+ type: 'custom',
+ className: 'group-shell',
+ position: groupPosition,
+ dragHandle: '.drag-handle',
+ style: { width: groupWidth, height: groupHeight },
+ data: {
+ label: 'group',
+ className: 'Group',
+ definition: null,
+ widgetValues: {},
+ runtimeValues: {},
+ collapsed: false,
+ expandedSize: { width: groupWidth, height: groupHeight },
+ proxyInputs: [],
+ proxyOutputs: [],
+ childCount: selectedNodes.length,
+ previewImage: null,
+ tableRows: null,
+ meshData: null,
+ overlay: null,
+ scalarValue: null,
+ processingTimeMs: null,
+ warning: null,
+ },
+ selected: true,
+ };
+
+ const nodeMap = new Map(currentNodes.map((node) => [String(node.id), node]));
+ const nextNodes = [
+ ...currentNodes.map((node) => {
+ if (!selectedIds.includes(String(node.id))) {
+ return { ...node, selected: false };
+ }
+ const absolute = getNodeAbsolutePosition(node, nodeMap);
+ return {
+ ...node,
+ selected: false,
+ parentId: groupId,
+ extent: 'parent',
+ hidden: false,
+ position: {
+ x: absolute.x - groupPosition.x,
+ y: absolute.y - groupPosition.y,
+ },
+ };
+ }),
+ groupNode,
+ ];
+
+ setNodes(nextNodes);
+ setTimeout(() => refreshGroupNode(groupId, nextNodes, reactFlow.getEdges()), 0);
+ }, [reactFlow, refreshGroupNode, setNodes]);
+
const setNodeOutputs = useCallback((nodeId, output, outputName, extraDefinitionPatch = {}) => {
setNodes((prev) => prev.map((node) => {
if (node.id !== nodeId) return node;
@@ -516,9 +1035,12 @@ function Flow() {
(e) => e.target === nodeId && getInputName(e.targetHandle) === 'path'
);
if (!edge) return null;
- const sourceNode = reactFlow.getNode(edge.source);
+ const original = edge.data?.groupProxyOriginal || {};
+ const sourceId = original.source || edge.source;
+ const sourceHandle = original.sourceHandle || edge.sourceHandle;
+ const sourceNode = reactFlow.getNode(sourceId);
const outputPaths = sourceNode?.data?.definition?.output_paths;
- const outputSlot = getOutputSlot(edge.sourceHandle);
+ const outputSlot = getOutputSlot(sourceHandle);
if (Array.isArray(outputPaths) && typeof outputPaths[outputSlot] === 'string') {
return outputPaths[outputSlot];
}
@@ -653,38 +1175,66 @@ function Flow() {
// ── Connection handling ─────────────────────────────────────────────
const isValidConnection = useCallback((connection) => {
- const srcType = getHandleType(connection.sourceHandle);
- const tgtType = getHandleType(connection.targetHandle);
+ const srcType = getConnectionHandleType(connection.sourceHandle);
+ const tgtType = getConnectionHandleType(connection.targetHandle);
return socketTypesCompatible(srcType, tgtType);
}, []);
const onConnect = useCallback((params) => {
- const type = getHandleType(params.sourceHandle);
+ const sourceProxy = parseGroupProxyHandle(params.sourceHandle);
+ const targetProxy = parseGroupProxyHandle(params.targetHandle);
+ const type = getConnectionHandleType(params.sourceHandle);
const color = TYPE_COLORS[type] || 'var(--fallback-type)';
+ const edgePayload = {
+ ...params,
+ style: { stroke: color, strokeWidth: 2 },
+ };
+ const proxyOriginal = {};
+ if (sourceProxy) {
+ proxyOriginal.source = sourceProxy.nodeId;
+ proxyOriginal.sourceHandle = sourceProxy.realHandle;
+ }
+ if (targetProxy) {
+ proxyOriginal.target = targetProxy.nodeId;
+ proxyOriginal.targetHandle = targetProxy.realHandle;
+ }
+ if (Object.keys(proxyOriginal).length > 0) {
+ edgePayload.data = {
+ ...(edgePayload.data || {}),
+ groupProxyOwner: sourceProxy?.direction === 'out' ? params.source : params.target,
+ groupProxyOriginal: proxyOriginal,
+ };
+ }
+
setEdges((eds) => {
// Enforce single connection per input handle
const filtered = eds.filter(
(e) => !(e.target === params.target && e.targetHandle === params.targetHandle)
);
- return addEdge(
- { ...params, style: { stroke: color, strokeWidth: 2 } },
- filtered
- );
+ return addEdge(edgePayload, filtered);
});
- if (getInputName(params.targetHandle) === 'path') {
+ const effectiveTargetHandle = targetProxy?.realHandle || params.targetHandle;
+ const effectiveTargetNode = targetProxy?.nodeId || params.target;
+ if (getInputName(effectiveTargetHandle) === 'path') {
setTimeout(() => {
- refreshLoadNodeOutputs(params.target);
+ refreshLoadNodeOutputs(effectiveTargetNode);
}, 0);
}
- const targetNode = reactFlow.getNode(params.target);
+ const targetNode = reactFlow.getNode(effectiveTargetNode);
if (targetNode && (targetNode.data.className === 'Annotations' || targetNode.data.className === 'Markup')) {
setTimeout(() => {
- refreshAnnotationNodeOutputs(params.target);
+ refreshAnnotationNodeOutputs(effectiveTargetNode);
}, 0);
}
+ if (sourceProxy) {
+ setTimeout(() => refreshGroupNode(params.source), 0);
+ }
+ if (targetProxy) {
+ setTimeout(() => refreshGroupNode(params.target), 0);
+ }
scheduleAutoRun();
- }, [reactFlow, refreshAnnotationNodeOutputs, refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
+ }, [reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
const handleEdgesChange = useCallback((changes) => {
const currentEdges = reactFlow.getEdges();
@@ -721,7 +1271,68 @@ function Flow() {
});
}, 0);
}
- }, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshLoadNodeOutputs]);
+ setTimeout(() => {
+ reactFlow.getNodes()
+ .filter((node) => node.data?.className === 'Group')
+ .forEach((node) => refreshGroupNode(node.id));
+ }, 0);
+ }, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs]);
+
+ const handleNodesChange = useCallback((changes) => {
+ const currentNodes = reactFlow.getNodes();
+ const selectedGroupIds = new Set(
+ changes
+ .filter((change) => change.type === 'select' && change.selected)
+ .map((change) => String(change.id))
+ .filter((id) => currentNodes.some((node) => String(node.id) === id && node.data?.className === 'Group')),
+ );
+ const removedIds = new Set(
+ changes
+ .filter((change) => change.type === 'remove')
+ .map((change) => String(change.id)),
+ );
+
+ onNodesChange(changes);
+
+ if (selectedGroupIds.size > 0) {
+ const deselectedDescendantIds = new Set();
+ selectedGroupIds.forEach((groupId) => {
+ collectGroupDescendantIds(currentNodes, groupId).forEach((id) => deselectedDescendantIds.add(id));
+ });
+
+ if (deselectedDescendantIds.size > 0) {
+ setNodes((existing) => existing.map((node) => (
+ deselectedDescendantIds.has(String(node.id))
+ ? { ...node, selected: false }
+ : node
+ )));
+ }
+ }
+
+ if (removedIds.size === 0) return;
+
+ const groupIds = currentNodes
+ .filter((node) => removedIds.has(String(node.id)) && node.data?.className === 'Group')
+ .map((node) => String(node.id));
+ const removedWithDescendants = new Set(removedIds);
+ for (const groupId of groupIds) {
+ collectGroupDescendantIds(currentNodes, groupId).forEach((id) => removedWithDescendants.add(id));
+ }
+
+ if (groupIds.length > 0) {
+ setNodes((existing) => existing.filter((node) => !removedWithDescendants.has(String(node.id))));
+ setEdges((existing) => existing.filter((edge) => (
+ !removedWithDescendants.has(String(edge.source))
+ && !removedWithDescendants.has(String(edge.target))
+ )));
+ }
+
+ setTimeout(() => {
+ reactFlow.getNodes()
+ .filter((node) => node.data?.className === 'Group')
+ .forEach((node) => refreshGroupNode(node.id));
+ }, 0);
+ }, [onNodesChange, reactFlow, refreshGroupNode, setEdges, setNodes]);
// ── Drop-on-blank: open filtered context menu ──────────────────────
@@ -733,7 +1344,7 @@ function Flow() {
if (!fromHandle || !fromHandle.id) return;
const { clientX, clientY } = 'changedTouches' in event ? event.changedTouches[0] : event;
- const handleType = getHandleType(fromHandle.id);
+ const handleType = getConnectionHandleType(fromHandle.id);
setContextMenu({
x: clientX,
@@ -1058,7 +1669,9 @@ function Flow() {
onRuntimeValuesChange,
openFileBrowser,
onManualTrigger,
- }), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger]);
+ onToggleGroupCollapse: toggleGroupCollapse,
+ onUngroup: ungroupGroup,
+ }), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, toggleGroupCollapse, ungroupGroup]);
const clearGraph = useCallback(() => {
setNodes([]);
@@ -1298,8 +1911,20 @@ function Flow() {
}, []);
const onNodeDragStart = useCallback((event, node) => {
+ activeDragNodeIdRef.current = String(node.id);
+
if (!(event.ctrlKey || event.metaKey)) {
duplicateDragRef.current = null;
+ if (node.data?.className === 'Group') {
+ const descendantIds = collectGroupDescendantIds(reactFlow.getNodes(), node.id);
+ if (descendantIds.size > 0) {
+ setNodes((existing) => existing.map((candidate) => (
+ descendantIds.has(String(candidate.id))
+ ? { ...candidate, selected: false }
+ : candidate
+ )));
+ }
+ }
return;
}
@@ -1343,6 +1968,7 @@ function Flow() {
);
duplicateDragRef.current = {
+ anchorId: String(node.id),
draggedIds,
originPositions,
duplicateSourceById,
@@ -1361,104 +1987,178 @@ function Flow() {
}, [initializeDynamicNodes, reactFlow, setEdges, setNodes]);
const onNodeDrag = useCallback((_event, node) => {
+ if (String(node.id) !== activeDragNodeIdRef.current) return;
+
const duplicateState = duplicateDragRef.current;
- if (!duplicateState) return;
+ if (duplicateState) {
+ const anchorId = duplicateState.anchorId || duplicateState.draggedIds[0];
+ const anchorOrigin = duplicateState.originPositions[anchorId];
+ if (!anchorOrigin) return;
- const anchorId = duplicateState.draggedIds.includes(String(node.id))
- ? String(node.id)
- : duplicateState.draggedIds[0];
- const anchorOrigin = duplicateState.originPositions[anchorId];
- if (!anchorOrigin) return;
+ const offset = {
+ x: (Number(node.position?.x) || 0) - anchorOrigin.x,
+ y: (Number(node.position?.y) || 0) - anchorOrigin.y,
+ };
+ const draggedIdSet = new Set(duplicateState.draggedIds);
- const offset = {
- x: (Number(node.position?.x) || 0) - anchorOrigin.x,
- y: (Number(node.position?.y) || 0) - anchorOrigin.y,
- };
- const draggedIdSet = new Set(duplicateState.draggedIds);
+ setNodes((existing) => existing.map((candidate) => {
+ const candidateId = String(candidate.id);
+ const originalPosition = duplicateState.originPositions[candidateId];
+ if (draggedIdSet.has(candidateId) && originalPosition) {
+ return {
+ ...candidate,
+ selected: false,
+ position: originalPosition,
+ };
+ }
- setNodes((existing) => existing.map((candidate) => {
- const candidateId = String(candidate.id);
- const originalPosition = duplicateState.originPositions[candidateId];
- if (draggedIdSet.has(candidateId) && originalPosition) {
- return {
- ...candidate,
- selected: false,
- position: originalPosition,
- };
- }
+ const sourceId = duplicateState.duplicateSourceById[candidateId];
+ if (sourceId) {
+ const sourceOrigin = duplicateState.originPositions[sourceId];
+ if (!sourceOrigin) return candidate;
+ return {
+ ...candidate,
+ selected: true,
+ position: {
+ x: sourceOrigin.x + offset.x,
+ y: sourceOrigin.y + offset.y,
+ },
+ };
+ }
- const sourceId = duplicateState.duplicateSourceById[candidateId];
- if (sourceId) {
- const sourceOrigin = duplicateState.originPositions[sourceId];
- if (!sourceOrigin) return candidate;
- return {
- ...candidate,
- selected: true,
- position: {
- x: sourceOrigin.x + offset.x,
- y: sourceOrigin.y + offset.y,
- },
- };
- }
-
- return candidate;
- }));
+ return candidate;
+ }));
+ return;
+ }
}, [setNodes]);
- const onNodeDragStop = useCallback((_event, node) => {
+ const onNodeDragStop = useCallback((event, node) => {
+ if (String(node.id) !== activeDragNodeIdRef.current) return;
+ activeDragNodeIdRef.current = null;
+
const duplicateState = duplicateDragRef.current;
duplicateDragRef.current = null;
- if (!duplicateState) return;
+ if (duplicateState) {
+ const anchorId = duplicateState.anchorId || duplicateState.draggedIds[0];
+ const anchorOrigin = duplicateState.originPositions[anchorId];
+ if (!anchorOrigin) return;
- const anchorId = duplicateState.draggedIds.includes(String(node.id))
- ? String(node.id)
- : duplicateState.draggedIds[0];
- const anchorOrigin = duplicateState.originPositions[anchorId];
- if (!anchorOrigin) return;
+ const offset = {
+ x: (Number(node.position?.x) || 0) - anchorOrigin.x,
+ y: (Number(node.position?.y) || 0) - anchorOrigin.y,
+ };
+ const draggedIdSet = new Set(duplicateState.draggedIds);
- const offset = {
- x: (Number(node.position?.x) || 0) - anchorOrigin.x,
- y: (Number(node.position?.y) || 0) - anchorOrigin.y,
- };
- const draggedIdSet = new Set(duplicateState.draggedIds);
+ setNodes((existing) => existing.map((candidate) => {
+ const candidateId = String(candidate.id);
+ const originalPosition = duplicateState.originPositions[candidateId];
+ if (draggedIdSet.has(candidateId) && originalPosition) {
+ return {
+ ...candidate,
+ selected: false,
+ position: originalPosition,
+ };
+ }
+
+ const sourceId = duplicateState.duplicateSourceById[candidateId];
+ if (sourceId) {
+ const sourceOrigin = duplicateState.originPositions[sourceId];
+ if (!sourceOrigin) return candidate;
+ return {
+ ...candidate,
+ selected: true,
+ position: {
+ x: sourceOrigin.x + offset.x,
+ y: sourceOrigin.y + offset.y,
+ },
+ };
+ }
- setNodes((existing) => existing.map((candidate) => {
- const candidateId = String(candidate.id);
- const originalPosition = duplicateState.originPositions[candidateId];
- if (draggedIdSet.has(candidateId) && originalPosition) {
return {
...candidate,
selected: false,
- position: originalPosition,
};
+ }));
+
+ setStatus({
+ text: `Duplicated ${Object.keys(duplicateState.duplicateSourceById).length} node${Object.keys(duplicateState.duplicateSourceById).length === 1 ? '' : 's'}.`,
+ level: 'info',
+ });
+ scheduleAutoRun();
+ return;
+ }
+
+ const currentNodes = reactFlow.getNodes();
+ const touchedGroupIds = new Set();
+ let nextNodes = currentNodes;
+ let changed = false;
+
+ const draggedNodes = node.data?.className === 'Group'
+ ? []
+ : (
+ node.selected
+ ? nextNodes.filter((candidate) => candidate.selected && candidate.data?.className !== 'Group')
+ : nextNodes.filter((candidate) => candidate.id === node.id)
+ );
+
+ if (draggedNodes.length > 0) {
+ const draggedIdSet = new Set(draggedNodes.map((candidate) => String(candidate.id)));
+ const targetGroup = findExpandedGroupDropTarget(nextNodes, Array.from(draggedIdSet), node.id);
+ if (targetGroup) {
+ const nodeMap = new Map(nextNodes.map((candidate) => [String(candidate.id), candidate]));
+ const targetRect = getGroupWorkspaceBounds(targetGroup, nodeMap);
+ const targetAbs = getNodeAbsolutePosition(targetGroup, nodeMap);
+ let joinedCount = 0;
+
+ nextNodes = nextNodes.map((candidate) => {
+ if (!draggedIdSet.has(String(candidate.id))) return candidate;
+
+ const center = getNodeCenter(candidate, nodeMap);
+ if (!rectContainsPoint(targetRect, center)) return candidate;
+
+ const absolute = getNodeAbsolutePosition(candidate, nodeMap);
+ const nextPosition = {
+ x: absolute.x - targetAbs.x,
+ y: absolute.y - targetAbs.y,
+ };
+ const alreadyInTarget = String(candidate.parentId || '') === String(targetGroup.id);
+ const samePosition = Math.abs((Number(candidate.position?.x) || 0) - nextPosition.x) < 0.5
+ && Math.abs((Number(candidate.position?.y) || 0) - nextPosition.y) < 0.5;
+ if (alreadyInTarget && samePosition) return candidate;
+
+ if (candidate.parentId) {
+ touchedGroupIds.add(String(candidate.parentId));
+ }
+ touchedGroupIds.add(String(targetGroup.id));
+ joinedCount += 1;
+ changed = true;
+ return {
+ ...candidate,
+ parentId: String(targetGroup.id),
+ extent: 'parent',
+ hidden: false,
+ position: nextPosition,
+ };
+ });
+
+ if (joinedCount > 0) {
+ setStatus({
+ text: `Added ${joinedCount} node${joinedCount === 1 ? '' : 's'} to group.`,
+ level: 'info',
+ });
+ }
}
+ }
- const sourceId = duplicateState.duplicateSourceById[candidateId];
- if (sourceId) {
- const sourceOrigin = duplicateState.originPositions[sourceId];
- if (!sourceOrigin) return candidate;
- return {
- ...candidate,
- selected: true,
- position: {
- x: sourceOrigin.x + offset.x,
- y: sourceOrigin.y + offset.y,
- },
- };
- }
+ if (!changed) return;
- return {
- ...candidate,
- selected: false,
- };
- }));
-
- setStatus({
- text: `Duplicated ${Object.keys(duplicateState.duplicateSourceById).length} node${Object.keys(duplicateState.duplicateSourceById).length === 1 ? '' : 's'}.`,
- level: 'info',
- });
- scheduleAutoRun();
- }, [scheduleAutoRun, setNodes]);
+ setNodes(nextNodes);
+ setTimeout(() => {
+ touchedGroupIds.forEach((groupId) => {
+ if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges());
+ });
+ }, 0);
+ }, [reactFlow, refreshGroupNode, scheduleAutoRun, setNodes]);
// ── Keyboard shortcut ───────────────────────────────────────────────
@@ -1531,6 +2231,8 @@ function Flow() {
return () => window.removeEventListener('pointerdown', handlePointerDown, true);
}, [contextMenu]);
+ const selectedNodeCount = nodes.filter((node) => node.selected).length;
+
// ── Render ──────────────────────────────────────────────────────────
return (
@@ -1569,7 +2271,7 @@ function Flow() {
setContextMenu(null)}
filterType={contextMenu.filterType}
filterDirection={contextMenu.filterDirection}
+ selectedNodeCount={selectedNodeCount}
/>
)}
diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx
index d4fb0a0..7ccccc6 100644
--- a/frontend/src/CustomNode.jsx
+++ b/frontend/src/CustomNode.jsx
@@ -24,6 +24,103 @@ function formatUiLabel(text) {
.toLowerCase();
}
+function parseProxyHandle(handleId) {
+ const text = String(handleId || '');
+ if (!text.startsWith('group-proxy::')) return null;
+ const parts = text.split('::');
+ if (parts.length < 5) return null;
+ return {
+ direction: parts[1],
+ nodeId: parts[2],
+ type: parts[3],
+ realHandle: decodeURIComponent(parts.slice(4).join('::')),
+ };
+}
+
+function GroupNode({ id, data }) {
+ const ctx = useContext(NodeContext);
+ const proxyInputs = Array.isArray(data.proxyInputs) ? data.proxyInputs : [];
+ const proxyOutputs = Array.isArray(data.proxyOutputs) ? data.proxyOutputs : [];
+ const childCount = Number(data.childCount) || 0;
+ const collapsed = !!data.collapsed;
+ const maxRows = Math.max(proxyInputs.length, proxyOutputs.length, collapsed ? 1 : 0);
+
+ return (
+
+
+
+
{formatUiLabel(data.label || 'group')}
+
+
+
+
+
+
+ {collapsed ? (
+ <>
+ {Array.from({ length: maxRows }, (_, index) => {
+ const input = proxyInputs[index];
+ const output = proxyOutputs[index];
+ return (
+
+
+ {input && (
+ <>
+
+ {formatUiLabel(input.label || input.name)}
+ >
+ )}
+
+
+ {output && (
+ <>
+ {formatUiLabel(output.label || output.name)}
+
+ >
+ )}
+
+
+ );
+ })}
+
{childCount} nodes
+ >
+ ) : (
+
+
workflow group
+
{childCount} nodes
+
+ )}
+
+
+ );
+}
+
class PreviewBoundary extends React.Component {
constructor(props) {
super(props);
@@ -390,6 +487,8 @@ function getSourceTypeForInput(store, nodeId, inputName) {
const targetHandle = `input::${inputName}::`;
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
if (!edge?.sourceHandle) return null;
+ const proxy = parseProxyHandle(edge.sourceHandle);
+ if (proxy) return proxy.type || null;
const parts = edge.sourceHandle.split('::');
return parts[2] || null;
}
@@ -405,8 +504,11 @@ function getConnectedOutputInfo(store, nodeId, inputName) {
const targetHandle = `input::${inputName}::`;
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
if (!edge?.sourceHandle) return null;
- const sourceNode = store.nodeLookup?.get(edge.source) || store.nodes?.find((n) => n.id === edge.source) || null;
- const slot = Number.parseInt(edge.sourceHandle.split('::')[1], 10);
+ const proxy = parseProxyHandle(edge.sourceHandle);
+ const sourceNodeId = proxy?.nodeId || edge.source;
+ const sourceHandle = proxy?.realHandle || edge.sourceHandle;
+ const sourceNode = store.nodeLookup?.get(sourceNodeId) || store.nodes?.find((n) => n.id === sourceNodeId) || null;
+ const slot = Number.parseInt(sourceHandle.split('::')[1], 10);
if (!sourceNode || !Number.isInteger(slot)) return null;
return {
path: sourceNode.data?.definition?.output_paths?.[slot] || null,
@@ -751,6 +853,9 @@ function NodeTable({ rows }) {
function CustomNode({ id, data }) {
const ctx = useContext(NodeContext);
+ if (data.className === 'Group') {
+ return ;
+ }
const def = data.definition;
const scalarDisplay = formatScalarDisplay(data.scalarValue);
const processingTimeText = formatProcessingTime(data.processingTimeMs);
diff --git a/frontend/src/executionGraph.js b/frontend/src/executionGraph.js
index 1bc0cf7..759a3f5 100644
--- a/frontend/src/executionGraph.js
+++ b/frontend/src/executionGraph.js
@@ -1,4 +1,4 @@
-import { DATA_TYPES } from './constants';
+import { DATA_TYPES } from './constants.js';
function getInputName(handleId) {
return handleId.split('::')[1];
@@ -8,11 +8,24 @@ function getOutputSlot(handleId) {
return parseInt(handleId.split('::')[1], 10);
}
+function resolveExecutionEdge(edge) {
+ const original = edge?.data?.groupProxyOriginal;
+ if (!original) return edge;
+ return {
+ ...edge,
+ source: original.source || edge.source,
+ sourceHandle: original.sourceHandle || edge.sourceHandle,
+ target: original.target || edge.target,
+ targetHandle: original.targetHandle || edge.targetHandle,
+ };
+}
+
export function getConnectedNodeIds(edges) {
const connectedNodeIds = new Set();
for (const edge of edges) {
- connectedNodeIds.add(edge.source);
- connectedNodeIds.add(edge.target);
+ const resolved = resolveExecutionEdge(edge);
+ connectedNodeIds.add(resolved.source);
+ connectedNodeIds.add(resolved.target);
}
return connectedNodeIds;
}
@@ -53,6 +66,7 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
if (!runnableNodeIds.has(node.id)) continue;
const { className, definition, widgetValues, runtimeValues } = node.data;
+ if (className === 'Group') continue;
if (!definition) continue;
if (excludeManualTrigger && definition.manual_trigger) continue;
@@ -72,7 +86,9 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
}
}
- const incoming = edges.filter((edge) => edge.target === node.id);
+ const incoming = edges
+ .map(resolveExecutionEdge)
+ .filter((edge) => edge.target === node.id);
for (const edge of incoming) {
const inputName = getInputName(edge.targetHandle);
const outputSlot = getOutputSlot(edge.sourceHandle);
@@ -97,12 +113,15 @@ export function hasBlockingAutoRunInput(node, edges) {
const required = def.input.required || {};
for (const [name, spec] of Object.entries(required)) {
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
- const hiddenByConnectedInput = (() => {
- const raw = opts?.hide_when_input_connected;
- if (!raw) return false;
- const inputs = Array.isArray(raw) ? raw : [raw];
- return inputs.some((inputName) => edges.some(
- (edge) => edge.target === node.id && getInputName(edge.targetHandle) === String(inputName)
+ const hiddenByConnectedInput = (() => {
+ const raw = opts?.hide_when_input_connected;
+ if (!raw) return false;
+ const inputs = Array.isArray(raw) ? raw : [raw];
+ return inputs.some((inputName) => edges.some(
+ (edge) => {
+ const resolved = resolveExecutionEdge(edge);
+ return resolved.target === node.id && getInputName(resolved.targetHandle) === String(inputName);
+ }
));
})();
@@ -114,7 +133,10 @@ export function hasBlockingAutoRunInput(node, edges) {
}
if (!DATA_TYPES.has(type)) continue;
const hasEdge = edges.some(
- (edge) => edge.target === node.id && getInputName(edge.targetHandle) === name
+ (edge) => {
+ const resolved = resolveExecutionEdge(edge);
+ return resolved.target === node.id && getInputName(resolved.targetHandle) === name;
+ }
);
if (!hasEdge) return true;
}
diff --git a/frontend/src/nodeClipboard.js b/frontend/src/nodeClipboard.js
index b00506f..1726fb1 100644
--- a/frontend/src/nodeClipboard.js
+++ b/frontend/src/nodeClipboard.js
@@ -18,13 +18,52 @@ function clonePlainObject(value) {
return cloneValue(value) || {};
}
+function collectSelectedNodeIds(nodes, nodeIds) {
+ const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id)));
+ if (selectedIdSet.size === 0) return selectedIdSet;
+
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const node of Array.isArray(nodes) ? nodes : []) {
+ const parentId = node?.parentId ? String(node.parentId) : null;
+ const nodeId = String(node?.id);
+ if (parentId && selectedIdSet.has(parentId) && !selectedIdSet.has(nodeId)) {
+ selectedIdSet.add(nodeId);
+ changed = true;
+ }
+ }
+ }
+ return selectedIdSet;
+}
+
+function extractExtraData(data) {
+ const source = data || {};
+ return Object.fromEntries(
+ Object.entries(source).filter(([key]) => ![
+ 'label',
+ 'className',
+ 'widgetValues',
+ 'runtimeValues',
+ 'definition',
+ 'previewImage',
+ 'tableRows',
+ 'meshData',
+ 'overlay',
+ 'scalarValue',
+ 'processingTimeMs',
+ 'warning',
+ ].includes(key)),
+ );
+}
+
export function buildNodeClipboardPayloadForIds(
nodes,
edges,
nodeIds,
{ includeIncomingExternalEdges = false } = {},
) {
- const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id)));
+ const selectedIdSet = collectSelectedNodeIds(nodes, nodeIds);
const selectedNodes = Array.isArray(nodes)
? nodes.filter((node) => selectedIdSet.has(String(node.id)))
: [];
@@ -50,12 +89,18 @@ export function buildNodeClipboardPayloadForIds(
x: Number(node.position?.x) || 0,
y: Number(node.position?.y) || 0,
},
+ ...(node.className ? { className: node.className } : {}),
+ ...(node.parentId ? { parentId: String(node.parentId) } : {}),
+ ...(node.extent ? { extent: node.extent } : {}),
+ ...(node.hidden ? { hidden: true } : {}),
+ ...(node.style ? { style: cloneValue(node.style) } : {}),
dragHandle: node.dragHandle || '.drag-handle',
data: {
label: node.data?.label || node.data?.className || 'Node',
className: node.data?.className || '',
widgetValues: clonePlainObject(node.data?.widgetValues),
runtimeValues: clonePlainObject(node.data?.runtimeValues),
+ extraData: clonePlainObject(extractExtraData(node.data)),
},
})),
edges: capturedEdges.map((edge) => ({
@@ -64,15 +109,19 @@ export function buildNodeClipboardPayloadForIds(
target: String(edge.target),
targetHandle: edge.targetHandle,
...(edge.style ? { style: { ...edge.style } } : {}),
+ ...(edge.hidden ? { hidden: true } : {}),
+ ...(edge.data ? { data: cloneValue(edge.data) } : {}),
})),
};
}
export function buildNodeClipboardPayload(nodes, edges) {
- const selectedIds = Array.isArray(nodes)
- ? nodes.filter((node) => node?.selected).map((node) => String(node.id))
+ const selectedNodes = Array.isArray(nodes)
+ ? nodes.filter((node) => node?.selected)
: [];
- return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds);
+ const selectedIds = selectedNodes.map((node) => String(node.id));
+ const includeIncomingExternalEdges = selectedNodes.some((node) => node?.data?.className === 'Group');
+ return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds, { includeIncomingExternalEdges });
}
export function parseNodeClipboardPayload(text) {
@@ -111,10 +160,15 @@ export function instantiateNodeClipboardPayload(
return {
id: newId,
type: node.type || 'custom',
+ className: node.className,
position: {
x: (Number(node.position?.x) || 0) + (Number(offset?.x) || 0),
y: (Number(node.position?.y) || 0) + (Number(offset?.y) || 0),
},
+ ...(node.parentId ? { parentId: idMap.get(String(node.parentId)) || String(node.parentId) } : {}),
+ ...(node.extent ? { extent: node.extent } : {}),
+ ...(node.hidden ? { hidden: true } : {}),
+ ...(node.style ? { style: cloneValue(node.style) } : {}),
dragHandle: node.dragHandle || '.drag-handle',
selected: true,
data: {
@@ -122,6 +176,7 @@ export function instantiateNodeClipboardPayload(
className,
widgetValues: clonePlainObject(node.data?.widgetValues),
runtimeValues: clonePlainObject(node.data?.runtimeValues),
+ ...(clonePlainObject(node.data?.extraData)),
definition,
previewImage: null,
tableRows: null,
@@ -147,6 +202,8 @@ export function instantiateNodeClipboardPayload(
targetHandle: edge.targetHandle,
selected: false,
...(edge.style ? { style: { ...edge.style } } : {}),
+ ...(edge.hidden ? { hidden: true } : {}),
+ ...(edge.data ? { data: cloneValue(edge.data) } : {}),
}));
return {
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index a76a579..22ffc63 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -236,8 +236,104 @@ html, body, #root {
overflow: hidden;
}
+.group-node {
+ width: 100%;
+ height: 100%;
+ min-width: 220px;
+ resize: none;
+ border-style: dashed;
+ display: flex;
+ flex-direction: column;
+ background:
+ linear-gradient(180deg, rgba(30, 41, 59, 0.82), rgba(15, 23, 42, 0.72));
+ box-shadow:
+ inset 0 0 0 1px rgba(148, 163, 184, 0.08),
+ inset 0 1px 18px rgba(15, 23, 42, 0.28);
+}
+
+.group-node-title {
+ background: #334155;
+}
+
+.group-node-title .node-title-main {
+ flex: 1;
+}
+
+.group-node-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: auto;
+}
+
+.group-toggle {
+ border: 0;
+ background: rgba(15, 23, 42, 0.65);
+ color: var(--text-heading);
+ border-radius: 4px;
+ padding: 2px 8px;
+ cursor: pointer;
+ font-size: 12px;
+ line-height: 1;
+}
+
+.group-toggle-collapse {
+ min-width: 24px;
+ padding: 2px 6px;
+}
+
+.group-node-summary {
+ padding: 6px 10px;
+ color: var(--text-secondary);
+ font-size: 10px;
+ border-top: 1px solid var(--border-subtle);
+}
+
+.group-node .node-body {
+ flex: 1;
+ min-height: 0;
+}
+
+.group-node-expanded .node-body {
+ padding: 12px;
+}
+
+.group-node-workspace {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-height: 120px;
+ border-radius: 8px;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ background:
+ linear-gradient(180deg, rgba(15, 23, 42, 0.16), rgba(15, 23, 42, 0.34));
+ box-shadow:
+ inset 0 0 0 1px rgba(15, 23, 42, 0.12),
+ inset 0 12px 28px rgba(15, 23, 42, 0.18);
+ pointer-events: none;
+}
+
+.group-node-workspace-label {
+ position: absolute;
+ top: 10px;
+ left: 12px;
+ color: rgba(148, 163, 184, 0.58);
+ font-size: 10px;
+ letter-spacing: 0.06em;
+ text-transform: lowercase;
+}
+
+.group-node-expanded .group-node-summary {
+ position: absolute;
+ right: 10px;
+ bottom: 8px;
+ border-top: 0;
+ padding: 0;
+ background: transparent;
+}
+
/* Let React Flow node wrapper fit to the custom-node's size */
-.react-flow__node-custom {
+.react-flow__node-custom:not(.group-shell) {
width: auto !important;
height: auto !important;
}
diff --git a/frontend/src/workflowCapture.js b/frontend/src/workflowCapture.js
index b75a17f..7b59aa7 100644
--- a/frontend/src/workflowCapture.js
+++ b/frontend/src/workflowCapture.js
@@ -1,5 +1,5 @@
import { toBlob } from 'html-to-image';
-import { CANVAS_COLORS } from './constants';
+import { CANVAS_COLORS } from './constants.js';
export const OVERLAY_CAPTURE_SELECTORS = [
'.lineplot-overlay',
diff --git a/frontend/src/workflowHydration.js b/frontend/src/workflowHydration.js
index 3e97032..bdc1e55 100644
--- a/frontend/src/workflowHydration.js
+++ b/frontend/src/workflowHydration.js
@@ -40,18 +40,26 @@ export function hydrateWorkflowState(data, defs = {}) {
return {
...node,
type: node.type || 'custom',
+ className: node.className,
+ parentId: node.parentId,
+ extent: node.extent,
+ hidden: !!node.hidden,
+ style: node.style,
dragHandle: node.dragHandle || '.drag-handle',
data: {
...node.data,
label: node.data?.label || node.data?.className || 'Node',
widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition),
- runtimeValues: {},
+ runtimeValues: node.data?.runtimeValues || {},
+ ...(node.data?.extraData || {}),
definition,
previewImage: null,
tableRows: null,
meshData: null,
overlay: null,
scalarValue: null,
+ processingTimeMs: null,
+ warning: null,
},
};
});
diff --git a/frontend/src/workflowSerialization.js b/frontend/src/workflowSerialization.js
index cdac583..170914a 100644
--- a/frontend/src/workflowSerialization.js
+++ b/frontend/src/workflowSerialization.js
@@ -1,15 +1,44 @@
export function serializeWorkflowState(nodes, edges) {
+ const compactObject = (value) => {
+ if (!value || typeof value !== 'object') return null;
+ const entries = Object.entries(value);
+ return entries.length > 0 ? Object.fromEntries(entries) : null;
+ };
+ const getExtraData = (data) => compactObject(Object.fromEntries(
+ Object.entries(data || {}).filter(([key]) => ![
+ 'label',
+ 'className',
+ 'widgetValues',
+ 'runtimeValues',
+ 'definition',
+ 'previewImage',
+ 'tableRows',
+ 'meshData',
+ 'overlay',
+ 'scalarValue',
+ 'processingTimeMs',
+ 'warning',
+ ].includes(key))
+ ));
+
return {
version: 1,
nodes: nodes.map((node) => ({
id: node.id,
type: node.type || 'custom',
position: node.position,
+ ...(node.className ? { className: node.className } : {}),
+ ...(node.parentId ? { parentId: node.parentId } : {}),
+ ...(node.extent ? { extent: node.extent } : {}),
+ ...(node.hidden ? { hidden: true } : {}),
+ ...(node.style ? { style: node.style } : {}),
dragHandle: node.dragHandle || '.drag-handle',
data: {
label: node.data?.label || node.data?.className || 'Node',
className: node.data?.className || '',
widgetValues: node.data?.widgetValues || {},
+ ...(compactObject(node.data?.runtimeValues) ? { runtimeValues: compactObject(node.data?.runtimeValues) } : {}),
+ ...(getExtraData(node.data) ? { extraData: getExtraData(node.data) } : {}),
output: node.data?.definition?.output || [],
output_name: node.data?.definition?.output_name || [],
},
@@ -21,6 +50,8 @@ export function serializeWorkflowState(nodes, edges) {
target: edge.target,
targetHandle: edge.targetHandle,
...(edge.style ? { style: edge.style } : {}),
+ ...(edge.hidden ? { hidden: true } : {}),
+ ...(edge.data ? { data: edge.data } : {}),
})),
};
}
diff --git a/frontend/tests/executionGraph.test.mjs b/frontend/tests/executionGraph.test.mjs
index 1c4c763..c6f1354 100644
--- a/frontend/tests/executionGraph.test.mjs
+++ b/frontend/tests/executionGraph.test.mjs
@@ -192,6 +192,73 @@ test('serializeExecutionGraph allows a singleton ImageDemo graph so previews can
});
});
+test('serializeExecutionGraph ignores group shells and resolves collapsed proxy edges back to child endpoints', () => {
+ const nodes = [
+ {
+ id: '1',
+ data: {
+ className: 'Image',
+ definition: {
+ input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
+ manual_trigger: false,
+ },
+ widgetValues: { filename: 'scan.gwy' },
+ },
+ },
+ {
+ id: '10',
+ data: {
+ className: 'Group',
+ definition: null,
+ widgetValues: {},
+ },
+ },
+ {
+ id: '2',
+ parentId: '10',
+ hidden: true,
+ data: {
+ className: 'PreviewImage',
+ definition: {
+ input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
+ manual_trigger: false,
+ },
+ widgetValues: {},
+ },
+ },
+ ];
+
+ const edges = [
+ {
+ source: '1',
+ sourceHandle: 'output::0::DATA_FIELD',
+ target: '10',
+ targetHandle: 'group-proxy::in::2::DATA_FIELD::input%3A%3Afield%3A%3ADATA_FIELD',
+ data: {
+ groupProxyOwner: '10',
+ groupProxyOriginal: {
+ target: '2',
+ targetHandle: 'input::field::DATA_FIELD',
+ },
+ },
+ },
+ ];
+
+ const prompt = serializeExecutionGraph(nodes, edges);
+
+ assert.deepEqual(prompt, {
+ '1': {
+ class_type: 'Image',
+ inputs: { filename: 'scan.gwy' },
+ },
+ '2': {
+ class_type: 'PreviewImage',
+ inputs: { field: ['1', 0] },
+ },
+ });
+ assert.equal('10' in prompt, false);
+});
+
test('getAutoRunnableNodes ignores disconnected nodes when deciding what can auto-run', () => {
const nodes = [
{ id: '1', data: { definition: {}, widgetValues: {} } },
diff --git a/frontend/tests/nodeClipboard.test.mjs b/frontend/tests/nodeClipboard.test.mjs
index 682ba4f..09fef6a 100644
--- a/frontend/tests/nodeClipboard.test.mjs
+++ b/frontend/tests/nodeClipboard.test.mjs
@@ -265,3 +265,28 @@ test('clipboard payload deep-copies local widget and runtime fields', () => {
assert.equal(payload.nodes[0].data.widgetValues.markup_shapes[0].points[0], 0.1);
assert.equal(payload.nodes[0].data.runtimeValues.camera.azimuth, 15);
});
+
+test('clipboard payload preserves wrapper class names for group shells', () => {
+ const payload = buildNodeClipboardPayloadForIds(
+ [
+ {
+ id: '50',
+ type: 'custom',
+ className: 'group-shell',
+ position: { x: 0, y: 0 },
+ data: {
+ label: 'group',
+ className: 'Group',
+ widgetValues: {},
+ },
+ },
+ ],
+ [],
+ ['50'],
+ );
+
+ const instantiated = instantiateNodeClipboardPayload(payload, {}, 80);
+
+ assert.equal(payload.nodes[0].className, 'group-shell');
+ assert.equal(instantiated.nodes[0].className, 'group-shell');
+});
diff --git a/frontend/tests/workflowSerialization.test.mjs b/frontend/tests/workflowSerialization.test.mjs
index 0bf80e4..f9ad553 100644
--- a/frontend/tests/workflowSerialization.test.mjs
+++ b/frontend/tests/workflowSerialization.test.mjs
@@ -226,3 +226,26 @@ test('hydrateWorkflowState clears saved folder selections on shared workflows',
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['PATH']);
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['path']);
});
+
+test('workflow serialization preserves wrapper class names for group shells', () => {
+ const nodes = [
+ {
+ id: '31',
+ type: 'custom',
+ className: 'group-shell',
+ position: { x: 5, y: 15 },
+ style: { width: 420, height: 260 },
+ data: {
+ label: 'group',
+ className: 'Group',
+ widgetValues: {},
+ },
+ },
+ ];
+
+ const serialized = serializeWorkflowState(nodes, []);
+ const hydrated = hydrateWorkflowState(serialized, {});
+
+ assert.equal(serialized.nodes[0].className, 'group-shell');
+ assert.equal(hydrated.nodes[0].className, 'group-shell');
+});