state.activeWorkflowId)
@@ -64,8 +65,18 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
diffStatus: isPreview ? undefined : diffStatus,
runPathStatus,
isPreviewSelection: isPreview && isPreviewSelected,
+ isGroupedSelection: !isPreview && isGroupedSelection,
}),
- [isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
+ [
+ isActive,
+ isPending,
+ isDeletedBlock,
+ diffStatus,
+ runPathStatus,
+ isPreview,
+ isPreviewSelected,
+ isGroupedSelection,
+ ]
)
return {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts
index be4bc6bdfc..4b7961e1b7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts
@@ -35,6 +35,7 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
const block = blocks[n.id]
const parentId = block?.data?.parentId
const parentType = parentId ? blocks[parentId]?.type : undefined
+ const groupId = block?.data?.groupId
return {
id: n.id,
type: block?.type || '',
@@ -42,6 +43,7 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType,
+ groupId,
}
}),
[blocks]
@@ -49,14 +51,22 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
/**
* Handle right-click on a node (block)
+ * If the node is part of a multiselection, include all selected nodes.
+ * If the node is not selected, just use that node.
*/
const handleNodeContextMenu = useCallback(
(event: React.MouseEvent, node: Node) => {
event.preventDefault()
event.stopPropagation()
- const selectedNodes = getNodes().filter((n) => n.selected)
- const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node]
+ // Get all currently selected nodes
+ const allNodes = getNodes()
+ const selectedNodes = allNodes.filter((n) => n.selected)
+
+ // If the right-clicked node is already selected, use all selected nodes
+ // Otherwise, just use the right-clicked node
+ const isNodeSelected = selectedNodes.some((n) => n.id === node.id)
+ const nodesToUse = isNodeSelected && selectedNodes.length > 0 ? selectedNodes : [node]
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts
index 634d28a86e..2ee44e64a0 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts
@@ -11,6 +11,7 @@ export interface BlockRingOptions {
diffStatus: BlockDiffStatus
runPathStatus: BlockRunPathStatus
isPreviewSelection?: boolean
+ isGroupedSelection?: boolean
}
/**
@@ -21,8 +22,15 @@ export function getBlockRingStyles(options: BlockRingOptions): {
hasRing: boolean
ringClassName: string
} {
- const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
- options
+ const {
+ isActive,
+ isPending,
+ isDeletedBlock,
+ diffStatus,
+ runPathStatus,
+ isPreviewSelection,
+ isGroupedSelection,
+ } = options
const hasRing =
isActive ||
@@ -30,17 +38,24 @@ export function getBlockRingStyles(options: BlockRingOptions): {
diffStatus === 'new' ||
diffStatus === 'edited' ||
isDeletedBlock ||
- !!runPathStatus
+ !!runPathStatus ||
+ !!isGroupedSelection
const ringClassName = cn(
+ // Grouped selection: more transparent ring for blocks selected as part of a group
+ // Using rgba with the brand-secondary color (#33b4ff) at 40% opacity
+ isGroupedSelection &&
+ !isActive &&
+ 'ring-[2px] ring-[rgba(51,180,255,0.4)]',
// Preview selection: static blue ring (standard thickness, no animation)
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
// Executing block: pulsing success ring with prominent thickness
isActive &&
!isPreviewSelection &&
+ !isGroupedSelection &&
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
- // Non-active states use standard ring utilities
- !isActive && hasRing && 'ring-[1.75px]',
+ // Non-active states use standard ring utilities (except grouped selection which has its own)
+ !isActive && hasRing && !isGroupedSelection && 'ring-[1.75px]',
// Pending state: warning ring
!isActive && isPending && 'ring-[var(--warning)]',
// Deleted state (highest priority after active/pending)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index f4e2b54883..d6c6411ec4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -264,12 +264,14 @@ const WorkflowContent = React.memo(() => {
const canUndo = undoRedoStack.undo.length > 0
const canRedo = undoRedoStack.redo.length > 0
- const { updateNodeDimensions, setDragStartPosition, getDragStartPosition } = useWorkflowStore(
- useShallow((state) => ({
- updateNodeDimensions: state.updateNodeDimensions,
- setDragStartPosition: state.setDragStartPosition,
- getDragStartPosition: state.getDragStartPosition,
- }))
+ const { updateNodeDimensions, setDragStartPosition, getDragStartPosition, getGroups } =
+ useWorkflowStore(
+ useShallow((state) => ({
+ updateNodeDimensions: state.updateNodeDimensions,
+ setDragStartPosition: state.setDragStartPosition,
+ getDragStartPosition: state.getDragStartPosition,
+ getGroups: state.getGroups,
+ }))
)
const copilotCleanup = useCopilotStore((state) => state.cleanup)
@@ -361,6 +363,14 @@ const WorkflowContent = React.memo(() => {
new Map()
)
+ /**
+ * Stores original positions and parentIds for nodes temporarily parented during group drag.
+ * Key: node ID, Value: { originalPosition, originalParentId }
+ */
+ const groupDragTempParentsRef = useRef<
+ Map
+ >(new Map())
+
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
const pendingSelectionRef = useRef | null>(null)
@@ -458,6 +468,8 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
+ collaborativeGroupBlocks,
+ collaborativeUngroupBlocks,
undo,
redo,
} = useCollaborativeWorkflow()
@@ -782,6 +794,35 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleBlockHandles(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
+ const handleContextGroupBlocks = useCallback(() => {
+ const blockIds = contextMenuBlocks.map((block) => block.id)
+ if (blockIds.length >= 2) {
+ // Validate that all blocks share the same parent (or all have no parent)
+ // Blocks inside a subflow cannot be grouped with blocks outside that subflow
+ const parentIds = contextMenuBlocks.map((block) => block.parentId || null)
+ const uniqueParentIds = new Set(parentIds)
+ if (uniqueParentIds.size > 1) {
+ addNotification({
+ level: 'error',
+ message: 'Cannot group blocks from different subflows',
+ })
+ return
+ }
+ collaborativeGroupBlocks(blockIds)
+ }
+ }, [contextMenuBlocks, collaborativeGroupBlocks, addNotification])
+
+ const handleContextUngroupBlocks = useCallback(() => {
+ // Find the first block with a groupId
+ const groupedBlock = contextMenuBlocks.find((block) => block.groupId)
+ if (!groupedBlock?.groupId) return
+
+ // The block's groupId is the group we want to ungroup
+ // This is the direct group the block belongs to, which is the "top level" from the user's perspective
+ // (the most recently created group that contains this block)
+ collaborativeUngroupBlocks(groupedBlock.groupId)
+ }, [contextMenuBlocks, collaborativeUngroupBlocks])
+
const handleContextRemoveFromSubflow = useCallback(() => {
const blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -1906,6 +1947,7 @@ const WorkflowContent = React.memo(() => {
name: block.name,
isActive,
isPending,
+ groupId: block.data?.groupId,
},
// Include dynamic dimensions for container resizing calculations (must match rendered size)
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
@@ -2060,16 +2102,56 @@ const WorkflowContent = React.memo(() => {
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
- /** Handles node changes - applies changes and resolves parent-child selection conflicts. */
+ /** Handles node changes - applies changes and resolves parent-child selection conflicts.
+ * Also expands selection to include all group members when a grouped block is selected.
+ */
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setDisplayNodes((nds) => {
- const updated = applyNodeChanges(changes, nds)
+ let updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some((c) => c.type === 'select')
- return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
+
+ if (hasSelectionChange) {
+ // Expand selection to include all group members
+ const groups = getGroups()
+ const selectedNodeIds = new Set(updated.filter((n) => n.selected).map((n) => n.id))
+ const groupsToInclude = new Set()
+
+ // Find all groups that have at least one selected member
+ selectedNodeIds.forEach((nodeId) => {
+ const groupId = blocks[nodeId]?.data?.groupId
+ if (groupId && groups[groupId]) {
+ groupsToInclude.add(groupId)
+ }
+ })
+
+ // Add all blocks from those groups to the selection
+ if (groupsToInclude.size > 0) {
+ const expandedNodeIds = new Set(selectedNodeIds)
+ groupsToInclude.forEach((groupId) => {
+ const group = groups[groupId]
+ if (group) {
+ group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
+ }
+ })
+
+ // Update nodes to include expanded selection
+ if (expandedNodeIds.size > selectedNodeIds.size) {
+ updated = updated.map((n) => ({
+ ...n,
+ selected: expandedNodeIds.has(n.id) ? true : n.selected,
+ }))
+ }
+ }
+
+ // Resolve parent-child conflicts
+ updated = resolveParentChildSelectionConflicts(updated, blocks)
+ }
+
+ return updated
})
},
- [blocks]
+ [blocks, getGroups]
)
/**
@@ -2530,9 +2612,55 @@ const WorkflowContent = React.memo(() => {
parentId: currentParentId,
})
- // Capture all selected nodes' positions for multi-node undo/redo
+ // Expand selection to include all group members before capturing positions
+ const groups = getGroups()
const allNodes = getNodes()
- const selectedNodes = allNodes.filter((n) => n.selected)
+
+ // Find the group of the dragged node
+ const draggedBlockGroupId = blocks[node.id]?.data?.groupId
+
+ // If the dragged node is in a group, expand selection to include all group members
+ if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
+ const group = groups[draggedBlockGroupId]
+ const groupBlockIds = new Set(group.blockIds)
+
+ // Check if we need to expand selection
+ const currentSelectedIds = new Set(allNodes.filter((n) => n.selected).map((n) => n.id))
+ const needsExpansion = [...groupBlockIds].some((id) => !currentSelectedIds.has(id))
+
+ if (needsExpansion) {
+ setNodes((nodes) =>
+ nodes.map((n) => {
+ const isInGroup = groupBlockIds.has(n.id)
+ const isDirectlyDragged = n.id === node.id
+ return {
+ ...n,
+ selected: isInGroup ? true : n.selected,
+ data: {
+ ...n.data,
+ // Mark as grouped selection if in group but not the directly dragged node
+ isGroupedSelection: isInGroup && !isDirectlyDragged && !n.selected,
+ },
+ }
+ })
+ )
+ }
+ }
+
+ // Capture all selected nodes' positions for multi-node undo/redo
+ // Re-get nodes after potential selection expansion
+ const updatedNodes = getNodes()
+ const selectedNodes = updatedNodes.filter((n) => {
+ // Always include the dragged node
+ if (n.id === node.id) return true
+ // Include node if it's selected OR if it's in the same group as the dragged node
+ if (n.selected) return true
+ if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
+ return groups[draggedBlockGroupId].blockIds.includes(n.id)
+ }
+ return false
+ })
+
multiNodeDragStartRef.current.clear()
selectedNodes.forEach((n) => {
const block = blocks[n.id]
@@ -2544,8 +2672,63 @@ const WorkflowContent = React.memo(() => {
})
}
})
+
+ // Set up temporary parent-child relationships for group members
+ // This leverages React Flow's built-in parent-child drag behavior
+ // BUT: Only do this if NOT all group members are already selected
+ // If all are selected, React Flow's native multiselect drag will handle it
+ groupDragTempParentsRef.current.clear()
+ if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
+ const group = groups[draggedBlockGroupId]
+ if (group.blockIds.length > 1) {
+ // Check if all group members are already selected
+ const allGroupMembersSelected = group.blockIds.every((blockId) =>
+ updatedNodes.find((n) => n.id === blockId && n.selected)
+ )
+
+ // Only use temporary parent approach if NOT all members are selected
+ // (i.e., when click-and-dragging on an unselected grouped block)
+ if (!allGroupMembersSelected) {
+ // Get the dragged node's absolute position for calculating relative positions
+ const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
+
+ setNodes((nodes) =>
+ nodes.map((n) => {
+ // Skip the dragged node - it becomes the temporary parent
+ if (n.id === node.id) return n
+
+ // Only process nodes in the same group
+ if (group.blockIds.includes(n.id)) {
+ // Store original position and parentId for restoration later
+ groupDragTempParentsRef.current.set(n.id, {
+ originalPosition: { ...n.position },
+ originalParentId: n.parentId,
+ })
+
+ // Get this node's absolute position
+ const nodeAbsPos = getNodeAbsolutePosition(n.id)
+
+ // Calculate position relative to the dragged node
+ const relativePosition = {
+ x: nodeAbsPos.x - draggedNodeAbsPos.x,
+ y: nodeAbsPos.y - draggedNodeAbsPos.y,
+ }
+
+ return {
+ ...n,
+ parentId: node.id, // Temporarily make this a child of the dragged node
+ position: relativePosition,
+ extent: undefined, // Remove extent constraint during drag
+ }
+ }
+ return n
+ })
+ )
+ }
+ }
+ }
},
- [blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
+ [blocks, setDragStartPosition, getNodes, setNodes, getGroups, potentialParentId, setPotentialParentId, getNodeAbsolutePosition]
)
/** Handles node drag stop to establish parent-child relationships. */
@@ -2553,13 +2736,93 @@ const WorkflowContent = React.memo(() => {
(_event: React.MouseEvent, node: any) => {
clearDragHighlights()
+ // Compute absolute positions for group members before restoring parentIds
+ // We need to do this first because getNodes() will return stale data after setNodes
+ const computedGroupPositions = new Map()
+ const draggedBlockGroupId = blocks[node.id]?.data?.groupId
+
+ if (groupDragTempParentsRef.current.size > 0) {
+ const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
+ const currentNodes = getNodes()
+
+ // Compute absolute positions for all temporarily parented nodes
+ for (const [nodeId, _tempData] of groupDragTempParentsRef.current) {
+ const nodeData = currentNodes.find((n) => n.id === nodeId)
+ if (nodeData) {
+ // The node's current position is relative to the dragged node
+ computedGroupPositions.set(nodeId, {
+ x: draggedNodeAbsPos.x + nodeData.position.x,
+ y: draggedNodeAbsPos.y + nodeData.position.y,
+ })
+ }
+ }
+
+ // Also store the dragged node's absolute position
+ computedGroupPositions.set(node.id, draggedNodeAbsPos)
+
+ // Restore temporary parent-child relationships
+ setNodes((nodes) =>
+ nodes.map((n) => {
+ const tempData = groupDragTempParentsRef.current.get(n.id)
+ if (tempData) {
+ const absolutePosition = computedGroupPositions.get(n.id) || n.position
+
+ return {
+ ...n,
+ parentId: tempData.originalParentId,
+ position: absolutePosition,
+ extent: tempData.originalParentId ? ('parent' as const) : undefined,
+ }
+ }
+ return n
+ })
+ )
+ groupDragTempParentsRef.current.clear()
+ }
+
// Get all selected nodes to update their positions too
const allNodes = getNodes()
- const selectedNodes = allNodes.filter((n) => n.selected)
+ let selectedNodes = allNodes.filter((n) => n.selected)
+
+ // If the dragged node is in a group, include all group members
+ if (draggedBlockGroupId) {
+ const groups = getGroups()
+ const group = groups[draggedBlockGroupId]
+ if (group && group.blockIds.length > 1) {
+ const groupBlockIds = new Set(group.blockIds)
+ // Include the dragged node and all group members that aren't already selected
+ const groupNodes = allNodes.filter(
+ (n) => groupBlockIds.has(n.id) && !selectedNodes.some((sn) => sn.id === n.id)
+ )
+ selectedNodes = [...selectedNodes, ...groupNodes]
+ // Also ensure the dragged node is included
+ if (!selectedNodes.some((n) => n.id === node.id)) {
+ const draggedNode = allNodes.find((n) => n.id === node.id)
+ if (draggedNode) {
+ selectedNodes = [...selectedNodes, draggedNode]
+ }
+ }
+ }
+ }
- // If multiple nodes are selected, update all their positions
+ // If multiple nodes are selected (or in a group), update all their positions
if (selectedNodes.length > 1) {
- const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
+ // Use pre-computed positions for group members, otherwise use computeClampedPositionUpdates
+ let positionUpdates: Array<{ id: string; position: { x: number; y: number } }>
+
+ if (computedGroupPositions.size > 0) {
+ // For group drags, use the pre-computed absolute positions
+ positionUpdates = selectedNodes.map((n) => {
+ const precomputedPos = computedGroupPositions.get(n.id)
+ if (precomputedPos) {
+ return { id: n.id, position: precomputedPos }
+ }
+ // For non-group members, use current position
+ return { id: n.id, position: n.position }
+ })
+ } else {
+ positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
+ }
collaborativeBatchUpdatePositions(positionUpdates, {
previousPositions: multiNodeDragStartRef.current,
})
@@ -2843,6 +3106,7 @@ const WorkflowContent = React.memo(() => {
},
[
getNodes,
+ setNodes,
dragStartParentId,
potentialParentId,
updateNodeParent,
@@ -2861,6 +3125,7 @@ const WorkflowContent = React.memo(() => {
activeWorkflowId,
collaborativeBatchUpdatePositions,
collaborativeBatchUpdateParent,
+ getGroups,
]
)
@@ -3168,19 +3433,81 @@ const WorkflowContent = React.memo(() => {
/**
* Handles node click to select the node in ReactFlow.
+ * When clicking on a grouped block, also selects all other blocks in the group.
+ * Grouped blocks are marked with isGroupedSelection for different visual styling.
* Parent-child conflict resolution happens automatically in onNodesChange.
*/
const handleNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => {
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
- setNodes((nodes) =>
- nodes.map((n) => ({
- ...n,
- selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
- }))
- )
+ const groups = getGroups()
+
+ // Track which nodes are directly clicked vs. group-expanded
+ const directlySelectedIds = new Set()
+
+ setNodes((nodes) => {
+ // First, calculate the base selection
+ let updatedNodes = nodes.map((n) => {
+ const isDirectlySelected = isMultiSelect
+ ? n.id === node.id
+ ? true
+ : n.selected
+ : n.id === node.id
+ if (isDirectlySelected) {
+ directlySelectedIds.add(n.id)
+ }
+ return {
+ ...n,
+ selected: isDirectlySelected,
+ data: {
+ ...n.data,
+ isGroupedSelection: false, // Reset grouped selection flag
+ },
+ }
+ })
+
+ // Expand selection to include all group members
+ const selectedNodeIds = new Set(updatedNodes.filter((n) => n.selected).map((n) => n.id))
+ const groupsToInclude = new Set()
+
+ // Find all groups that have at least one selected member
+ selectedNodeIds.forEach((nodeId) => {
+ const groupId = blocks[nodeId]?.data?.groupId
+ if (groupId && groups[groupId]) {
+ groupsToInclude.add(groupId)
+ }
+ })
+
+ // Add all blocks from those groups to the selection
+ if (groupsToInclude.size > 0) {
+ const expandedNodeIds = new Set(selectedNodeIds)
+ groupsToInclude.forEach((groupId) => {
+ const group = groups[groupId]
+ if (group) {
+ group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
+ }
+ })
+
+ // Update nodes with expanded selection, marking group-expanded nodes
+ if (expandedNodeIds.size > selectedNodeIds.size) {
+ updatedNodes = updatedNodes.map((n) => {
+ const isGroupExpanded = expandedNodeIds.has(n.id) && !directlySelectedIds.has(n.id)
+ return {
+ ...n,
+ selected: expandedNodeIds.has(n.id) ? true : n.selected,
+ data: {
+ ...n.data,
+ isGroupedSelection: isGroupExpanded,
+ },
+ }
+ })
+ }
+ }
+
+ return updatedNodes
+ })
},
- [setNodes]
+ [setNodes, blocks, getGroups]
)
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
@@ -3415,6 +3742,8 @@ const WorkflowContent = React.memo(() => {
onRemoveFromSubflow={handleContextRemoveFromSubflow}
onOpenEditor={handleContextOpenEditor}
onRename={handleContextRename}
+ onGroupBlocks={handleContextGroupBlocks}
+ onUngroupBlocks={handleContextUngroupBlocks}
hasClipboard={hasClipboard()}
showRemoveFromSubflow={contextMenuBlocks.some(
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts
index c2fa032d86..b259c520b3 100644
--- a/apps/sim/hooks/use-collaborative-workflow.ts
+++ b/apps/sim/hooks/use-collaborative-workflow.ts
@@ -424,6 +424,35 @@ export function useCollaborativeWorkflow() {
logger.info('Successfully applied batch-update-parent from remote user')
break
}
+ case BLOCKS_OPERATIONS.GROUP_BLOCKS: {
+ const { blockIds, groupId } = payload
+ logger.info('Received group-blocks from remote user', {
+ userId,
+ groupId,
+ blockCount: (blockIds || []).length,
+ })
+
+ if (blockIds && blockIds.length > 0 && groupId) {
+ workflowStore.groupBlocks(blockIds, groupId)
+ }
+
+ logger.info('Successfully applied group-blocks from remote user')
+ break
+ }
+ case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: {
+ const { groupId } = payload
+ logger.info('Received ungroup-blocks from remote user', {
+ userId,
+ groupId,
+ })
+
+ if (groupId) {
+ workflowStore.ungroupBlocks(groupId)
+ }
+
+ logger.info('Successfully applied ungroup-blocks from remote user')
+ break
+ }
}
}
} catch (error) {
@@ -1584,6 +1613,83 @@ export function useCollaborativeWorkflow() {
]
)
+ const collaborativeGroupBlocks = useCallback(
+ (blockIds: string[]) => {
+ if (!isInActiveRoom()) {
+ logger.debug('Skipping group blocks - not in active workflow')
+ return null
+ }
+
+ if (blockIds.length < 2) {
+ logger.debug('Cannot group fewer than 2 blocks')
+ return null
+ }
+
+ const groupId = crypto.randomUUID()
+
+ const operationId = crypto.randomUUID()
+
+ addToQueue({
+ id: operationId,
+ operation: {
+ operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
+ target: OPERATION_TARGETS.BLOCKS,
+ payload: { blockIds, groupId },
+ },
+ workflowId: activeWorkflowId || '',
+ userId: session?.user?.id || 'unknown',
+ })
+
+ workflowStore.groupBlocks(blockIds, groupId)
+
+ undoRedo.recordGroupBlocks(blockIds, groupId)
+
+ logger.info('Grouped blocks collaboratively', { groupId, blockCount: blockIds.length })
+ return groupId
+ },
+ [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
+ )
+
+ const collaborativeUngroupBlocks = useCallback(
+ (groupId: string) => {
+ if (!isInActiveRoom()) {
+ logger.debug('Skipping ungroup blocks - not in active workflow')
+ return []
+ }
+
+ const groups = workflowStore.getGroups()
+ const group = groups[groupId]
+
+ if (!group) {
+ logger.warn('Cannot ungroup - group not found', { groupId })
+ return []
+ }
+
+ const blockIds = [...group.blockIds]
+
+ const operationId = crypto.randomUUID()
+
+ addToQueue({
+ id: operationId,
+ operation: {
+ operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
+ target: OPERATION_TARGETS.BLOCKS,
+ payload: { groupId, blockIds },
+ },
+ workflowId: activeWorkflowId || '',
+ userId: session?.user?.id || 'unknown',
+ })
+
+ workflowStore.ungroupBlocks(groupId)
+
+ undoRedo.recordUngroupBlocks(groupId, blockIds)
+
+ logger.info('Ungrouped blocks collaboratively', { groupId, blockCount: blockIds.length })
+ return blockIds
+ },
+ [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
+ )
+
return {
// Connection status
isConnected,
@@ -1622,6 +1728,10 @@ export function useCollaborativeWorkflow() {
collaborativeUpdateIterationCount,
collaborativeUpdateIterationCollection,
+ // Collaborative block group operations
+ collaborativeGroupBlocks,
+ collaborativeUngroupBlocks,
+
// Direct access to stores for non-collaborative operations
workflowStore,
subBlockStore,
diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts
index 1bb6bf590c..7a48e8b91c 100644
--- a/apps/sim/hooks/use-undo-redo.ts
+++ b/apps/sim/hooks/use-undo-redo.ts
@@ -22,7 +22,9 @@ import {
type BatchToggleHandlesOperation,
type BatchUpdateParentOperation,
createOperationEntry,
+ type GroupBlocksOperation,
runWithUndoRedoRecordingSuspended,
+ type UngroupBlocksOperation,
type UpdateParentOperation,
useUndoRedoStore,
} from '@/stores/undo-redo'
@@ -874,6 +876,46 @@ export function useUndoRedo() {
})
break
}
+ case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
+ // Undoing group = ungroup (inverse is ungroup operation)
+ const inverseOp = entry.inverse as unknown as UngroupBlocksOperation
+ const { groupId } = inverseOp.data
+
+ addToQueue({
+ id: opId,
+ operation: {
+ operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
+ target: OPERATION_TARGETS.BLOCKS,
+ payload: { groupId, blockIds: inverseOp.data.blockIds },
+ },
+ workflowId: activeWorkflowId,
+ userId,
+ })
+
+ workflowStore.ungroupBlocks(groupId)
+ logger.debug('Undid group blocks', { groupId })
+ break
+ }
+ case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
+ // Undoing ungroup = re-group (inverse is group operation)
+ const inverseOp = entry.inverse as unknown as GroupBlocksOperation
+ const { groupId, blockIds } = inverseOp.data
+
+ addToQueue({
+ id: opId,
+ operation: {
+ operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
+ target: OPERATION_TARGETS.BLOCKS,
+ payload: { groupId, blockIds },
+ },
+ workflowId: activeWorkflowId,
+ userId,
+ })
+
+ workflowStore.groupBlocks(blockIds, groupId)
+ logger.debug('Undid ungroup blocks', { groupId })
+ break
+ }
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
const applyDiffInverse = entry.inverse as any
const { baselineSnapshot } = applyDiffInverse.data
@@ -1482,6 +1524,46 @@ export function useUndoRedo() {
})
break
}
+ case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
+ // Redo group = group again
+ const groupOp = entry.operation as GroupBlocksOperation
+ const { groupId, blockIds } = groupOp.data
+
+ addToQueue({
+ id: opId,
+ operation: {
+ operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
+ target: OPERATION_TARGETS.BLOCKS,
+ payload: { groupId, blockIds },
+ },
+ workflowId: activeWorkflowId,
+ userId,
+ })
+
+ workflowStore.groupBlocks(blockIds, groupId)
+ logger.debug('Redid group blocks', { groupId })
+ break
+ }
+ case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
+ // Redo ungroup = ungroup again
+ const ungroupOp = entry.operation as UngroupBlocksOperation
+ const { groupId } = ungroupOp.data
+
+ addToQueue({
+ id: opId,
+ operation: {
+ operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
+ target: OPERATION_TARGETS.BLOCKS,
+ payload: { groupId, blockIds: ungroupOp.data.blockIds },
+ },
+ workflowId: activeWorkflowId,
+ userId,
+ })
+
+ workflowStore.ungroupBlocks(groupId)
+ logger.debug('Redid ungroup blocks', { groupId })
+ break
+ }
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
// Redo apply-diff means re-applying the proposed state with diff markers
const applyDiffOp = entry.operation as any
@@ -1793,6 +1875,66 @@ export function useUndoRedo() {
[activeWorkflowId, userId, undoRedoStore]
)
+ const recordGroupBlocks = useCallback(
+ (blockIds: string[], groupId: string) => {
+ if (!activeWorkflowId || blockIds.length === 0) return
+
+ const operation: GroupBlocksOperation = {
+ id: crypto.randomUUID(),
+ type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
+ timestamp: Date.now(),
+ workflowId: activeWorkflowId,
+ userId,
+ data: { groupId, blockIds },
+ }
+
+ const inverse: UngroupBlocksOperation = {
+ id: crypto.randomUUID(),
+ type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
+ timestamp: Date.now(),
+ workflowId: activeWorkflowId,
+ userId,
+ data: { groupId, blockIds },
+ }
+
+ const entry = createOperationEntry(operation, inverse)
+ undoRedoStore.push(activeWorkflowId, userId, entry)
+
+ logger.debug('Recorded group blocks', { groupId, blockCount: blockIds.length })
+ },
+ [activeWorkflowId, userId, undoRedoStore]
+ )
+
+ const recordUngroupBlocks = useCallback(
+ (groupId: string, blockIds: string[], parentGroupId?: string) => {
+ if (!activeWorkflowId || blockIds.length === 0) return
+
+ const operation: UngroupBlocksOperation = {
+ id: crypto.randomUUID(),
+ type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
+ timestamp: Date.now(),
+ workflowId: activeWorkflowId,
+ userId,
+ data: { groupId, blockIds, parentGroupId },
+ }
+
+ const inverse: GroupBlocksOperation = {
+ id: crypto.randomUUID(),
+ type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
+ timestamp: Date.now(),
+ workflowId: activeWorkflowId,
+ userId,
+ data: { groupId, blockIds },
+ }
+
+ const entry = createOperationEntry(operation, inverse)
+ undoRedoStore.push(activeWorkflowId, userId, entry)
+
+ logger.debug('Recorded ungroup blocks', { groupId, blockCount: blockIds.length })
+ },
+ [activeWorkflowId, userId, undoRedoStore]
+ )
+
return {
recordBatchAddBlocks,
recordBatchRemoveBlocks,
@@ -1806,6 +1948,8 @@ export function useUndoRedo() {
recordApplyDiff,
recordAcceptDiff,
recordRejectDiff,
+ recordGroupBlocks,
+ recordUngroupBlocks,
undo,
redo,
getStackSizes,
diff --git a/apps/sim/lib/workflows/autolayout/index.ts b/apps/sim/lib/workflows/autolayout/index.ts
index 6683660338..18035adfec 100644
--- a/apps/sim/lib/workflows/autolayout/index.ts
+++ b/apps/sim/lib/workflows/autolayout/index.ts
@@ -16,9 +16,61 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AutoLayout')
+/** Default block dimensions for layout calculations */
+const DEFAULT_BLOCK_WIDTH = 250
+const DEFAULT_BLOCK_HEIGHT = 100
+
+/**
+ * Identifies groups from blocks and calculates their bounding boxes.
+ * Returns a map of groupId to group info including bounding box and member block IDs.
+ */
+function identifyGroups(blocks: Record): Map<
+ string,
+ {
+ blockIds: string[]
+ bounds: { minX: number; minY: number; maxX: number; maxY: number }
+ }
+> {
+ const groups = new Map<
+ string,
+ {
+ blockIds: string[]
+ bounds: { minX: number; minY: number; maxX: number; maxY: number }
+ }
+ >()
+
+ // Group blocks by their groupId
+ for (const [blockId, block] of Object.entries(blocks)) {
+ const groupId = block.data?.groupId
+ if (!groupId) continue
+
+ if (!groups.has(groupId)) {
+ groups.set(groupId, {
+ blockIds: [],
+ bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
+ })
+ }
+
+ const group = groups.get(groupId)!
+ group.blockIds.push(blockId)
+
+ // Update bounding box
+ const blockWidth = block.data?.width ?? DEFAULT_BLOCK_WIDTH
+ const blockHeight = block.data?.height ?? block.height ?? DEFAULT_BLOCK_HEIGHT
+
+ group.bounds.minX = Math.min(group.bounds.minX, block.position.x)
+ group.bounds.minY = Math.min(group.bounds.minY, block.position.y)
+ group.bounds.maxX = Math.max(group.bounds.maxX, block.position.x + blockWidth)
+ group.bounds.maxY = Math.max(group.bounds.maxY, block.position.y + blockHeight)
+ }
+
+ return groups
+}
+
/**
* Applies automatic layout to all blocks in a workflow.
* Positions blocks in layers based on their connections (edges).
+ * Groups are treated as single units and laid out together.
*/
export function applyAutoLayout(
blocks: Record,
@@ -36,6 +88,11 @@ export function applyAutoLayout(
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING
const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING
+ // Identify groups and their bounding boxes
+ const groups = identifyGroups(blocksCopy)
+
+ logger.info('Identified block groups for layout', { groupCount: groups.size })
+
// Pre-calculate container dimensions by laying out their children (bottom-up)
// This ensures accurate widths/heights before root-level layout
prepareContainerDimensions(
@@ -49,19 +106,112 @@ export function applyAutoLayout(
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
+ // For groups, we need to:
+ // 1. Create virtual blocks representing each group
+ // 2. Replace grouped blocks with their group's virtual block
+ // 3. Layout the virtual blocks + ungrouped blocks
+ // 4. Apply position deltas to grouped blocks
+
+ // Track which blocks are in groups at root level
+ const groupedRootBlockIds = new Set()
+ const groupRepresentatives = new Map() // groupId -> representative blockId
+
+ // Store ORIGINAL positions of all grouped blocks before any modifications
+ const originalBlockPositions = new Map()
+ for (const [_groupId, group] of groups) {
+ for (const blockId of group.blockIds) {
+ if (blocksCopy[blockId]) {
+ originalBlockPositions.set(blockId, { ...blocksCopy[blockId].position })
+ }
+ }
+ }
+
+ for (const [groupId, group] of groups) {
+ // Find if any blocks in this group are at root level
+ const rootGroupBlocks = group.blockIds.filter((id) => layoutRootIds.includes(id))
+ if (rootGroupBlocks.length > 0) {
+ // Mark all blocks in this group as grouped
+ for (const blockId of rootGroupBlocks) {
+ groupedRootBlockIds.add(blockId)
+ }
+ // Use the first block as the group's representative for layout
+ const representativeId = rootGroupBlocks[0]
+ groupRepresentatives.set(groupId, representativeId)
+
+ // Update the representative block's dimensions to match the group's bounding box
+ const bounds = group.bounds
+ const groupWidth = bounds.maxX - bounds.minX
+ const groupHeight = bounds.maxY - bounds.minY
+
+ blocksCopy[representativeId] = {
+ ...blocksCopy[representativeId],
+ data: {
+ ...blocksCopy[representativeId].data,
+ width: groupWidth,
+ height: groupHeight,
+ },
+ // Position at the group's top-left corner
+ position: { x: bounds.minX, y: bounds.minY },
+ }
+ }
+ }
+
+ // Build the blocks to layout: ungrouped blocks + group representatives
const rootBlocks: Record = {}
for (const id of layoutRootIds) {
- rootBlocks[id] = blocksCopy[id]
+ // Skip grouped blocks that aren't representatives
+ if (groupedRootBlockIds.has(id)) {
+ // Only include if this is a group representative
+ for (const [groupId, repId] of groupRepresentatives) {
+ if (repId === id) {
+ rootBlocks[id] = blocksCopy[id]
+ break
+ }
+ }
+ } else {
+ rootBlocks[id] = blocksCopy[id]
+ }
}
- const rootEdges = edges.filter(
- (edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target)
- )
+ // Remap edges: edges involving grouped blocks should connect to the representative
+ const blockToGroup = new Map() // blockId -> groupId
+ for (const [groupId, group] of groups) {
+ for (const blockId of group.blockIds) {
+ blockToGroup.set(blockId, groupId)
+ }
+ }
+
+ const layoutBlockIds = new Set(Object.keys(rootBlocks))
+ const rootEdges = edges
+ .map((edge) => {
+ let source = edge.source
+ let target = edge.target
+
+ // Remap source if it's in a group
+ const sourceGroupId = blockToGroup.get(source)
+ if (sourceGroupId && groupRepresentatives.has(sourceGroupId)) {
+ source = groupRepresentatives.get(sourceGroupId)!
+ }
+
+ // Remap target if it's in a group
+ const targetGroupId = blockToGroup.get(target)
+ if (targetGroupId && groupRepresentatives.has(targetGroupId)) {
+ target = groupRepresentatives.get(targetGroupId)!
+ }
+
+ return { ...edge, source, target }
+ })
+ .filter((edge) => layoutBlockIds.has(edge.source) && layoutBlockIds.has(edge.target))
// Calculate subflow depths before laying out root blocks
- // This ensures blocks connected to subflow ends are positioned correctly
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
+ // Store old positions for groups to calculate deltas
+ const oldGroupPositions = new Map()
+ for (const [groupId, repId] of groupRepresentatives) {
+ oldGroupPositions.set(groupId, { ...blocksCopy[repId].position })
+ }
+
if (Object.keys(rootBlocks).length > 0) {
const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, {
isContainer: false,
@@ -69,15 +219,49 @@ export function applyAutoLayout(
subflowDepths,
})
+ // Apply positions to ungrouped blocks and group representatives
for (const node of nodes.values()) {
blocksCopy[node.id].position = node.position
}
+
+ // For each group, calculate the delta and apply to ALL blocks in the group
+ for (const [groupId, repId] of groupRepresentatives) {
+ const oldGroupTopLeft = oldGroupPositions.get(groupId)!
+ const newGroupTopLeft = blocksCopy[repId].position
+ const deltaX = newGroupTopLeft.x - oldGroupTopLeft.x
+ const deltaY = newGroupTopLeft.y - oldGroupTopLeft.y
+
+ const group = groups.get(groupId)!
+ // Apply delta to ALL blocks in the group using their ORIGINAL positions
+ for (const blockId of group.blockIds) {
+ if (layoutRootIds.includes(blockId)) {
+ const originalPos = originalBlockPositions.get(blockId)
+ if (originalPos) {
+ blocksCopy[blockId].position = {
+ x: originalPos.x + deltaX,
+ y: originalPos.y + deltaY,
+ }
+ }
+ }
+ }
+
+ // Restore the representative's original dimensions
+ const originalBlock = blocks[repId]
+ if (originalBlock) {
+ blocksCopy[repId].data = {
+ ...blocksCopy[repId].data,
+ width: originalBlock.data?.width,
+ height: originalBlock.data?.height,
+ }
+ }
+ }
}
layoutContainers(blocksCopy, edges, options)
logger.info('Auto layout completed successfully', {
blockCount: Object.keys(blocksCopy).length,
+ groupCount: groups.size,
})
return {
diff --git a/apps/sim/lib/workflows/autolayout/targeted.ts b/apps/sim/lib/workflows/autolayout/targeted.ts
index 08afa57a5e..33c133a479 100644
--- a/apps/sim/lib/workflows/autolayout/targeted.ts
+++ b/apps/sim/lib/workflows/autolayout/targeted.ts
@@ -26,9 +26,53 @@ export interface TargetedLayoutOptions extends LayoutOptions {
horizontalSpacing?: number
}
+/**
+ * Identifies block groups from the blocks' groupId data.
+ * Returns a map of groupId to array of block IDs in that group.
+ */
+function identifyBlockGroups(blocks: Record): Map {
+ const groups = new Map()
+
+ for (const [blockId, block] of Object.entries(blocks)) {
+ const groupId = block.data?.groupId
+ if (!groupId) continue
+
+ if (!groups.has(groupId)) {
+ groups.set(groupId, [])
+ }
+ groups.get(groupId)!.push(blockId)
+ }
+
+ return groups
+}
+
+/**
+ * Expands changed block IDs to include all blocks in the same group.
+ * If any block in a group changed, all blocks in that group should be treated as changed.
+ */
+function expandChangedToGroups(
+ changedBlockIds: string[],
+ blockGroups: Map,
+ blocks: Record
+): Set {
+ const expandedSet = new Set(changedBlockIds)
+
+ for (const blockId of changedBlockIds) {
+ const groupId = blocks[blockId]?.data?.groupId
+ if (groupId && blockGroups.has(groupId)) {
+ for (const groupBlockId of blockGroups.get(groupId)!) {
+ expandedSet.add(groupBlockId)
+ }
+ }
+ }
+
+ return expandedSet
+}
+
/**
* Applies targeted layout to only reposition changed blocks.
* Unchanged blocks act as anchors to preserve existing layout.
+ * Blocks in groups are moved together as a unit.
*/
export function applyTargetedLayout(
blocks: Record,
@@ -45,9 +89,14 @@ export function applyTargetedLayout(
return blocks
}
- const changedSet = new Set(changedBlockIds)
const blocksCopy: Record = JSON.parse(JSON.stringify(blocks))
+ // Identify block groups
+ const blockGroups = identifyBlockGroups(blocksCopy)
+
+ // Expand changed set to include all blocks in affected groups
+ const changedSet = expandChangedToGroups(changedBlockIds, blockGroups, blocksCopy)
+
// Pre-calculate container dimensions by laying out their children (bottom-up)
// This ensures accurate widths/heights before root-level layout
prepareContainerDimensions(
@@ -71,7 +120,8 @@ export function applyTargetedLayout(
changedSet,
verticalSpacing,
horizontalSpacing,
- subflowDepths
+ subflowDepths,
+ blockGroups
)
for (const [parentId, childIds] of groups.children.entries()) {
@@ -83,7 +133,8 @@ export function applyTargetedLayout(
changedSet,
verticalSpacing,
horizontalSpacing,
- subflowDepths
+ subflowDepths,
+ blockGroups
)
}
@@ -92,6 +143,7 @@ export function applyTargetedLayout(
/**
* Layouts a group of blocks (either root level or within a container)
+ * Blocks in block groups are moved together as a unit.
*/
function layoutGroup(
parentId: string | null,
@@ -101,7 +153,8 @@ function layoutGroup(
changedSet: Set,
verticalSpacing: number,
horizontalSpacing: number,
- subflowDepths: Map
+ subflowDepths: Map,
+ blockGroups: Map
): void {
if (childIds.length === 0) return
@@ -141,7 +194,7 @@ function layoutGroup(
return
}
- // Store old positions for anchor calculation
+ // Store old positions for anchor calculation and group delta tracking
const oldPositions = new Map()
for (const id of layoutEligibleChildIds) {
const block = blocks[id]
@@ -185,14 +238,47 @@ function layoutGroup(
}
}
+ // Track which groups have already had their deltas applied
+ const processedGroups = new Set()
+
// Apply new positions only to blocks that need layout
for (const id of needsLayout) {
const block = blocks[id]
const newPos = layoutPositions.get(id)
if (!block || !newPos) continue
- block.position = {
- x: newPos.x + offsetX,
- y: newPos.y + offsetY,
+
+ const groupId = block.data?.groupId
+
+ // If this block is in a group, move all blocks in the group together
+ if (groupId && blockGroups.has(groupId) && !processedGroups.has(groupId)) {
+ processedGroups.add(groupId)
+
+ // Calculate the delta for this block (the one that needs layout)
+ const oldPos = oldPositions.get(id)
+ if (oldPos) {
+ const deltaX = newPos.x + offsetX - oldPos.x
+ const deltaY = newPos.y + offsetY - oldPos.y
+
+ // Apply delta to ALL blocks in the group using their original positions
+ for (const groupBlockId of blockGroups.get(groupId)!) {
+ const groupBlock = blocks[groupBlockId]
+ if (groupBlock && layoutEligibleChildIds.includes(groupBlockId)) {
+ const groupOriginalPos = oldPositions.get(groupBlockId)
+ if (groupOriginalPos) {
+ groupBlock.position = {
+ x: groupOriginalPos.x + deltaX,
+ y: groupOriginalPos.y + deltaY,
+ }
+ }
+ }
+ }
+ }
+ } else if (!groupId) {
+ // Non-grouped block - apply position normally
+ block.position = {
+ x: newPos.x + offsetX,
+ y: newPos.y + offsetY,
+ }
}
}
}
diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts
index 45ddc614a4..cd8e12e989 100644
--- a/apps/sim/lib/workflows/autolayout/utils.ts
+++ b/apps/sim/lib/workflows/autolayout/utils.ts
@@ -41,11 +41,18 @@ export function isContainerType(blockType: string): boolean {
}
/**
- * Checks if a block should be excluded from autolayout
+ * Checks if a block should be excluded from autolayout.
+ * Note blocks are excluded unless they are part of a group.
*/
-export function shouldSkipAutoLayout(block?: BlockState): boolean {
+export function shouldSkipAutoLayout(block?: BlockState, isInGroup?: boolean): boolean {
if (!block) return true
- return AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)
+ // If the block type is normally excluded (e.g., note), but it's in a group, include it
+ if (AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)) {
+ // Check if block is in a group - if so, include it in layout
+ const blockIsInGroup = isInGroup ?? !!block.data?.groupId
+ return !blockIsInGroup
+ }
+ return false
}
/**
diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts
index f22365d145..9b74e4e5a4 100644
--- a/apps/sim/lib/workflows/diff/diff-engine.ts
+++ b/apps/sim/lib/workflows/diff/diff-engine.ts
@@ -1174,5 +1174,6 @@ export function stripWorkflowDiffMarkers(state: WorkflowState): WorkflowState {
edges: structuredClone(state.edges || []),
loops: structuredClone(state.loops || {}),
parallels: structuredClone(state.parallels || {}),
+ groups: structuredClone(state.groups || {}),
}
}
diff --git a/apps/sim/socket/constants.ts b/apps/sim/socket/constants.ts
index 98f49d846e..7a703e74f9 100644
--- a/apps/sim/socket/constants.ts
+++ b/apps/sim/socket/constants.ts
@@ -16,6 +16,8 @@ export const BLOCKS_OPERATIONS = {
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_UPDATE_PARENT: 'batch-update-parent',
+ GROUP_BLOCKS: 'group-blocks',
+ UNGROUP_BLOCKS: 'ungroup-blocks',
} as const
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
@@ -87,6 +89,8 @@ export const UNDO_REDO_OPERATIONS = {
APPLY_DIFF: 'apply-diff',
ACCEPT_DIFF: 'accept-diff',
REJECT_DIFF: 'reject-diff',
+ GROUP_BLOCKS: 'group-blocks',
+ UNGROUP_BLOCKS: 'ungroup-blocks',
} as const
export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS]
diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts
index 1f52d46ef9..999f9ea2e0 100644
--- a/apps/sim/socket/database/operations.ts
+++ b/apps/sim/socket/database/operations.ts
@@ -810,6 +810,104 @@ async function handleBlocksOperationTx(
break
}
+ case BLOCKS_OPERATIONS.GROUP_BLOCKS: {
+ const { blockIds, groupId } = payload
+ if (!Array.isArray(blockIds) || blockIds.length === 0 || !groupId) {
+ logger.debug('Invalid payload for group blocks operation')
+ return
+ }
+
+ logger.info(`Grouping ${blockIds.length} blocks into group ${groupId} in workflow ${workflowId}`)
+
+ // Update blocks: set groupId and push to groupStack
+ for (const blockId of blockIds) {
+ const [currentBlock] = await tx
+ .select({ data: workflowBlocks.data })
+ .from(workflowBlocks)
+ .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
+ .limit(1)
+
+ if (!currentBlock) {
+ logger.warn(`Block ${blockId} not found for grouping`)
+ continue
+ }
+
+ const currentData = (currentBlock?.data || {}) as Record
+ const currentStack = Array.isArray(currentData.groupStack) ? currentData.groupStack : []
+ const updatedData = {
+ ...currentData,
+ groupId,
+ groupStack: [...currentStack, groupId],
+ }
+
+ await tx
+ .update(workflowBlocks)
+ .set({
+ data: updatedData,
+ updatedAt: new Date(),
+ })
+ .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
+ }
+
+ logger.debug(`Grouped ${blockIds.length} blocks into group ${groupId}`)
+ break
+ }
+
+ case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: {
+ const { groupId, blockIds } = payload
+ if (!groupId || !Array.isArray(blockIds)) {
+ logger.debug('Invalid payload for ungroup blocks operation')
+ return
+ }
+
+ logger.info(`Ungrouping ${blockIds.length} blocks from group ${groupId} in workflow ${workflowId}`)
+
+ // Update blocks: pop from groupStack and set groupId to the previous level
+ for (const blockId of blockIds) {
+ const [currentBlock] = await tx
+ .select({ data: workflowBlocks.data })
+ .from(workflowBlocks)
+ .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
+ .limit(1)
+
+ if (!currentBlock) {
+ logger.warn(`Block ${blockId} not found for ungrouping`)
+ continue
+ }
+
+ const currentData = (currentBlock?.data || {}) as Record
+ const currentStack = Array.isArray(currentData.groupStack) ? [...currentData.groupStack] : []
+
+ // Pop the current groupId from the stack
+ if (currentStack.length > 0 && currentStack[currentStack.length - 1] === groupId) {
+ currentStack.pop()
+ }
+
+ // The new groupId is the top of the remaining stack, or undefined if empty
+ const newGroupId = currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined
+
+ let updatedData: Record
+ if (newGroupId) {
+ updatedData = { ...currentData, groupId: newGroupId, groupStack: currentStack }
+ } else {
+ // Remove groupId and groupStack if stack is empty
+ const { groupId: _removed, groupStack: _removedStack, ...restData } = currentData
+ updatedData = restData
+ }
+
+ await tx
+ .update(workflowBlocks)
+ .set({
+ data: updatedData,
+ updatedAt: new Date(),
+ })
+ .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
+ }
+
+ logger.debug(`Ungrouped ${blockIds.length} blocks from group ${groupId}`)
+ break
+ }
+
default:
throw new Error(`Unsupported blocks operation: ${operation}`)
}
diff --git a/apps/sim/socket/handlers/operations.ts b/apps/sim/socket/handlers/operations.ts
index 9b74293bbf..026a683694 100644
--- a/apps/sim/socket/handlers/operations.ts
+++ b/apps/sim/socket/handlers/operations.ts
@@ -465,6 +465,70 @@ export function setupOperationsHandlers(
return
}
+ if (
+ target === OPERATION_TARGETS.BLOCKS &&
+ operation === BLOCKS_OPERATIONS.GROUP_BLOCKS
+ ) {
+ await persistWorkflowOperation(workflowId, {
+ operation,
+ target,
+ payload,
+ timestamp: operationTimestamp,
+ userId: session.userId,
+ })
+
+ room.lastModified = Date.now()
+
+ socket.to(workflowId).emit('workflow-operation', {
+ operation,
+ target,
+ payload,
+ timestamp: operationTimestamp,
+ senderId: socket.id,
+ userId: session.userId,
+ userName: session.userName,
+ metadata: { workflowId, operationId: crypto.randomUUID() },
+ })
+
+ if (operationId) {
+ socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
+ }
+
+ return
+ }
+
+ if (
+ target === OPERATION_TARGETS.BLOCKS &&
+ operation === BLOCKS_OPERATIONS.UNGROUP_BLOCKS
+ ) {
+ await persistWorkflowOperation(workflowId, {
+ operation,
+ target,
+ payload,
+ timestamp: operationTimestamp,
+ userId: session.userId,
+ })
+
+ room.lastModified = Date.now()
+
+ socket.to(workflowId).emit('workflow-operation', {
+ operation,
+ target,
+ payload,
+ timestamp: operationTimestamp,
+ senderId: socket.id,
+ userId: session.userId,
+ userName: session.userName,
+ metadata: { workflowId, operationId: crypto.randomUUID() },
+ })
+
+ if (operationId) {
+ socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
+ }
+
+ return
+ }
+
if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_ADD_EDGES) {
await persistWorkflowOperation(workflowId, {
operation,
diff --git a/apps/sim/socket/middleware/permissions.ts b/apps/sim/socket/middleware/permissions.ts
index 1ff6b09e8b..b9f6139c1d 100644
--- a/apps/sim/socket/middleware/permissions.ts
+++ b/apps/sim/socket/middleware/permissions.ts
@@ -30,6 +30,8 @@ const WRITE_OPERATIONS: string[] = [
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
+ BLOCKS_OPERATIONS.GROUP_BLOCKS,
+ BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
// Edge operations
EDGE_OPERATIONS.ADD,
EDGE_OPERATIONS.REMOVE,
diff --git a/apps/sim/socket/validation/schemas.ts b/apps/sim/socket/validation/schemas.ts
index 395b321028..236c3583ce 100644
--- a/apps/sim/socket/validation/schemas.ts
+++ b/apps/sim/socket/validation/schemas.ts
@@ -221,6 +221,30 @@ export const BatchUpdateParentSchema = z.object({
operationId: z.string().optional(),
})
+export const GroupBlocksSchema = z.object({
+ operation: z.literal(BLOCKS_OPERATIONS.GROUP_BLOCKS),
+ target: z.literal(OPERATION_TARGETS.BLOCKS),
+ payload: z.object({
+ blockIds: z.array(z.string()),
+ groupId: z.string(),
+ name: z.string().optional(),
+ }),
+ timestamp: z.number(),
+ operationId: z.string().optional(),
+})
+
+export const UngroupBlocksSchema = z.object({
+ operation: z.literal(BLOCKS_OPERATIONS.UNGROUP_BLOCKS),
+ target: z.literal(OPERATION_TARGETS.BLOCKS),
+ payload: z.object({
+ groupId: z.string(),
+ blockIds: z.array(z.string()),
+ parentGroupId: z.string().optional(),
+ }),
+ timestamp: z.number(),
+ operationId: z.string().optional(),
+})
+
export const WorkflowOperationSchema = z.union([
BlockOperationSchema,
BatchPositionUpdateSchema,
@@ -229,6 +253,8 @@ export const WorkflowOperationSchema = z.union([
BatchToggleEnabledSchema,
BatchToggleHandlesSchema,
BatchUpdateParentSchema,
+ GroupBlocksSchema,
+ UngroupBlocksSchema,
EdgeOperationSchema,
BatchAddEdgesSchema,
BatchRemoveEdgesSchema,
diff --git a/apps/sim/stores/copilot-training/store.ts b/apps/sim/stores/copilot-training/store.ts
index fc6a346769..b4e5121f2b 100644
--- a/apps/sim/stores/copilot-training/store.ts
+++ b/apps/sim/stores/copilot-training/store.ts
@@ -25,6 +25,7 @@ function captureWorkflowSnapshot(): WorkflowState {
edges: rawState.edges || [],
loops: rawState.loops || {},
parallels: rawState.parallels || {},
+ groups: rawState.groups || {},
lastSaved: Date.now(),
}
}
diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts
index f68aa66e68..70cd429cad 100644
--- a/apps/sim/stores/undo-redo/types.ts
+++ b/apps/sim/stores/undo-redo/types.ts
@@ -126,6 +126,23 @@ export interface RejectDiffOperation extends BaseOperation {
}
}
+export interface GroupBlocksOperation extends BaseOperation {
+ type: typeof UNDO_REDO_OPERATIONS.GROUP_BLOCKS
+ data: {
+ groupId: string
+ blockIds: string[]
+ }
+}
+
+export interface UngroupBlocksOperation extends BaseOperation {
+ type: typeof UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS
+ data: {
+ groupId: string
+ blockIds: string[]
+ parentGroupId?: string
+ }
+}
+
export type Operation =
| BatchAddBlocksOperation
| BatchRemoveBlocksOperation
@@ -139,6 +156,8 @@ export type Operation =
| ApplyDiffOperation
| AcceptDiffOperation
| RejectDiffOperation
+ | GroupBlocksOperation
+ | UngroupBlocksOperation
export interface OperationEntry {
id: string
diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts
index e747c2fd2d..e38a9215d5 100644
--- a/apps/sim/stores/undo-redo/utils.ts
+++ b/apps/sim/stores/undo-redo/utils.ts
@@ -6,8 +6,10 @@ import type {
BatchRemoveBlocksOperation,
BatchRemoveEdgesOperation,
BatchUpdateParentOperation,
+ GroupBlocksOperation,
Operation,
OperationEntry,
+ UngroupBlocksOperation,
} from '@/stores/undo-redo/types'
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
@@ -164,6 +166,30 @@ export function createInverseOperation(operation: Operation): Operation {
},
}
+ case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
+ const op = operation as GroupBlocksOperation
+ return {
+ ...operation,
+ type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
+ data: {
+ groupId: op.data.groupId,
+ blockIds: op.data.blockIds,
+ },
+ } as UngroupBlocksOperation
+ }
+
+ case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
+ const op = operation as UngroupBlocksOperation
+ return {
+ ...operation,
+ type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
+ data: {
+ groupId: op.data.groupId,
+ blockIds: op.data.blockIds,
+ },
+ } as GroupBlocksOperation
+ }
+
default: {
const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
diff --git a/apps/sim/stores/workflow-diff/utils.ts b/apps/sim/stores/workflow-diff/utils.ts
index 3245875f77..464dbceaee 100644
--- a/apps/sim/stores/workflow-diff/utils.ts
+++ b/apps/sim/stores/workflow-diff/utils.ts
@@ -16,6 +16,7 @@ export function cloneWorkflowState(state: WorkflowState): WorkflowState {
edges: structuredClone(state.edges || []),
loops: structuredClone(state.loops || {}),
parallels: structuredClone(state.parallels || {}),
+ groups: structuredClone(state.groups || {}),
}
}
diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts
index 152f6ef257..bd57e22024 100644
--- a/apps/sim/stores/workflows/registry/store.ts
+++ b/apps/sim/stores/workflows/registry/store.ts
@@ -298,11 +298,26 @@ export const useWorkflowRegistry = create()(
let workflowState: any
if (workflowData?.state) {
+ const blocks = workflowData.state.blocks || {}
+
+ // Reconstruct groups from blocks' groupId data
+ const reconstructedGroups: Record = {}
+ Object.entries(blocks).forEach(([blockId, block]: [string, any]) => {
+ const groupId = block?.data?.groupId
+ if (groupId) {
+ if (!reconstructedGroups[groupId]) {
+ reconstructedGroups[groupId] = { id: groupId, blockIds: [] }
+ }
+ reconstructedGroups[groupId].blockIds.push(blockId)
+ }
+ })
+
workflowState = {
- blocks: workflowData.state.blocks || {},
+ blocks,
edges: workflowData.state.edges || [],
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
+ groups: reconstructedGroups,
lastSaved: Date.now(),
deploymentStatuses: {},
}
@@ -312,6 +327,7 @@ export const useWorkflowRegistry = create()(
edges: [],
loops: {},
parallels: {},
+ groups: {},
deploymentStatuses: {},
lastSaved: Date.now(),
}
diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts
index 398c662812..e45dd6217b 100644
--- a/apps/sim/stores/workflows/workflow/store.ts
+++ b/apps/sim/stores/workflows/workflow/store.ts
@@ -95,6 +95,7 @@ const initialState = {
edges: [],
loops: {},
parallels: {},
+ groups: {},
lastSaved: undefined,
deploymentStatuses: {},
needsRedeployment: false,
@@ -577,6 +578,7 @@ export const useWorkflowStore = create()(
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
+ groups: state.groups,
lastSaved: state.lastSaved,
deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment,
@@ -597,6 +599,7 @@ export const useWorkflowStore = create()(
Object.keys(workflowState.parallels || {}).length > 0
? workflowState.parallels
: generateParallelBlocks(nextBlocks)
+ const nextGroups = workflowState.groups || state.groups
return {
...state,
@@ -604,6 +607,7 @@ export const useWorkflowStore = create()(
edges: nextEdges,
loops: nextLoops,
parallels: nextParallels,
+ groups: nextGroups,
deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses,
needsRedeployment:
workflowState.needsRedeployment !== undefined
@@ -1333,6 +1337,126 @@ export const useWorkflowStore = create()(
getDragStartPosition: () => {
return get().dragStartPosition || null
},
+
+ groupBlocks: (blockIds: string[], groupId?: string) => {
+ if (blockIds.length === 0) return ''
+
+ const newGroupId = groupId || crypto.randomUUID()
+ const currentGroups = get().groups || {}
+ const currentBlocks = get().blocks
+
+ // Create the new group with all selected block IDs
+ const updatedGroups = { ...currentGroups }
+ updatedGroups[newGroupId] = {
+ id: newGroupId,
+ blockIds: [...blockIds],
+ }
+
+ // Update blocks: set groupId and push to groupStack
+ const newBlocks = { ...currentBlocks }
+ for (const blockId of blockIds) {
+ if (newBlocks[blockId]) {
+ const currentData = newBlocks[blockId].data || {}
+ const currentStack = Array.isArray(currentData.groupStack) ? currentData.groupStack : []
+ newBlocks[blockId] = {
+ ...newBlocks[blockId],
+ data: {
+ ...currentData,
+ groupId: newGroupId,
+ groupStack: [...currentStack, newGroupId],
+ },
+ }
+ }
+ }
+
+ set({
+ blocks: newBlocks,
+ groups: updatedGroups,
+ })
+
+ get().updateLastSaved()
+ logger.info('Created block group', {
+ groupId: newGroupId,
+ blockCount: blockIds.length,
+ })
+ return newGroupId
+ },
+
+ ungroupBlocks: (groupId: string) => {
+ const currentGroups = get().groups || {}
+ const currentBlocks = get().blocks
+ const group = currentGroups[groupId]
+
+ if (!group) {
+ logger.warn('Attempted to ungroup non-existent group', { groupId })
+ return []
+ }
+
+ const blockIds = [...group.blockIds]
+
+ // Remove the group from the groups record
+ const updatedGroups = { ...currentGroups }
+ delete updatedGroups[groupId]
+
+ // Update blocks: pop from groupStack and set groupId to the previous level
+ const newBlocks = { ...currentBlocks }
+ for (const blockId of blockIds) {
+ if (newBlocks[blockId]) {
+ const currentData = { ...newBlocks[blockId].data }
+ const currentStack = Array.isArray(currentData.groupStack)
+ ? [...currentData.groupStack]
+ : []
+
+ // Pop the current groupId from the stack
+ if (currentStack.length > 0 && currentStack[currentStack.length - 1] === groupId) {
+ currentStack.pop()
+ }
+
+ // The new groupId is the top of the remaining stack, or undefined if empty
+ const newGroupId =
+ currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined
+
+ if (newGroupId) {
+ currentData.groupId = newGroupId
+ currentData.groupStack = currentStack
+ } else {
+ // Remove groupId and groupStack if stack is empty
+ delete currentData.groupId
+ delete currentData.groupStack
+ }
+
+ newBlocks[blockId] = {
+ ...newBlocks[blockId],
+ data: currentData,
+ }
+ }
+ }
+
+ set({
+ blocks: newBlocks,
+ groups: updatedGroups,
+ })
+
+ get().updateLastSaved()
+ logger.info('Ungrouped blocks', {
+ groupId,
+ blockCount: blockIds.length,
+ })
+ return blockIds
+ },
+
+ getGroupBlockIds: (groupId: string) => {
+ const groups = get().groups || {}
+ const group = groups[groupId]
+
+ if (!group) return []
+
+ return [...group.blockIds]
+ },
+
+ getGroups: () => {
+ return get().groups || {}
+ },
}),
{ name: 'workflow-store' }
)
diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts
index 43afa31a66..7696a6911f 100644
--- a/apps/sim/stores/workflows/workflow/types.ts
+++ b/apps/sim/stores/workflows/workflow/types.ts
@@ -63,6 +63,11 @@ export interface BlockData {
// Container node type (for ReactFlow node type determination)
type?: string
+
+ // Block group membership
+ groupId?: string
+ /** Stack of group IDs for hierarchical grouping (oldest to newest) */
+ groupStack?: string[]
}
export interface BlockLayoutState {
@@ -144,6 +149,20 @@ export interface Variable {
value: unknown
}
+/**
+ * Represents a group of blocks on the canvas.
+ * Groups can be nested (a group can contain other groups via block membership).
+ * When a block is in a group, it stores the groupId in its data.
+ */
+export interface BlockGroup {
+ /** Unique identifier for the group */
+ id: string
+ /** Optional display name for the group */
+ name?: string
+ /** Block IDs that are direct members of this group */
+ blockIds: string[]
+}
+
export interface DragStartPosition {
id: string
x: number
@@ -157,6 +176,8 @@ export interface WorkflowState {
lastSaved?: number
loops: Record
parallels: Record
+ /** Block groups for organizing blocks on the canvas */
+ groups?: Record
lastUpdate?: number
metadata?: {
name?: string
@@ -243,6 +264,28 @@ export interface WorkflowActions {
workflowState: WorkflowState,
options?: { updateLastSaved?: boolean }
) => void
+
+ // Block group operations
+ /**
+ * Groups the specified blocks together.
+ * If any blocks are already in a group, they are removed from their current group first.
+ * @returns The new group ID
+ */
+ groupBlocks: (blockIds: string[], groupId?: string) => string
+ /**
+ * Ungroups a group, removing it and releasing its blocks.
+ * If the group has a parent group, blocks are moved to the parent group.
+ * @returns The block IDs that were in the group
+ */
+ ungroupBlocks: (groupId: string) => string[]
+ /**
+ * Gets all block IDs in a group, including blocks in nested groups (recursive).
+ */
+ getGroupBlockIds: (groupId: string, recursive?: boolean) => string[]
+ /**
+ * Gets all groups in the workflow.
+ */
+ getGroups: () => Record
}
export type WorkflowStore = WorkflowState & WorkflowActions