From f04cd7c355b8f20169e0f6c909af81b1fff811fb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 18:23:50 -0800 Subject: [PATCH 1/5] Groups v0 --- .../context-menu/block-context-menu.tsx | 35 +++++ .../components/context-menu/types.ts | 4 + .../hooks/use-canvas-context-menu.ts | 14 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 126 +++++++++++++-- apps/sim/hooks/use-collaborative-workflow.ts | 111 ++++++++++++++ apps/sim/hooks/use-undo-redo.ts | 144 ++++++++++++++++++ apps/sim/lib/workflows/diff/diff-engine.ts | 1 + apps/sim/socket/constants.ts | 4 + apps/sim/socket/database/operations.ts | 83 ++++++++++ apps/sim/socket/handlers/operations.ts | 64 ++++++++ apps/sim/socket/middleware/permissions.ts | 2 + apps/sim/socket/validation/schemas.ts | 26 ++++ apps/sim/stores/copilot-training/store.ts | 1 + apps/sim/stores/undo-redo/types.ts | 19 +++ apps/sim/stores/undo-redo/utils.ts | 26 ++++ apps/sim/stores/workflow-diff/utils.ts | 1 + apps/sim/stores/workflows/workflow/store.ts | 133 ++++++++++++++++ apps/sim/stores/workflows/workflow/types.ts | 43 ++++++ 18 files changed, 821 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx index 8945b13dc8..a7889a1227 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx @@ -29,6 +29,8 @@ export function BlockContextMenu({ onRemoveFromSubflow, onOpenEditor, onRename, + onGroupBlocks, + onUngroupBlocks, hasClipboard = false, showRemoveFromSubflow = false, disableEdit = false, @@ -47,6 +49,14 @@ export function BlockContextMenu({ const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock + // Check if we can group: need at least 2 blocks selected + const canGroup = selectedBlocks.length >= 2 + + // Check if we can ungroup: at least one selected block must be in a group + // Ungrouping will ungroup all blocks in that group (the entire group, not just selected blocks) + const hasGroupedBlock = selectedBlocks.some((b) => !!b.groupId) + const canUngroup = hasGroupedBlock + const getToggleEnabledLabel = () => { if (allEnabled) return 'Disable' if (allDisabled) return 'Enable' @@ -141,6 +151,31 @@ export function BlockContextMenu({ )} + {/* Block group actions */} + {(canGroup || canUngroup) && } + {canGroup && ( + { + onGroupBlocks() + onClose() + }} + > + Group Blocks + + )} + {canUngroup && ( + { + onUngroupBlocks() + onClose() + }} + > + Ungroup + + )} + {/* Single block actions */} {isSingleBlock && } {isSingleBlock && !isSubflow && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts index ed0ecd26ee..18990070f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts @@ -24,6 +24,8 @@ export interface ContextMenuBlockInfo { parentId?: string /** Parent type ('loop' | 'parallel') if nested */ parentType?: string + /** Group ID if block is in a group */ + groupId?: string } /** @@ -50,6 +52,8 @@ export interface BlockContextMenuProps { onRemoveFromSubflow: () => void onOpenEditor: () => void onRename: () => void + onGroupBlocks: () => void + onUngroupBlocks: () => void /** Whether clipboard has content for pasting */ hasClipboard?: boolean /** Whether remove from subflow option should be shown */ 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]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index f4e2b54883..6023127237 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) @@ -458,6 +460,8 @@ const WorkflowContent = React.memo(() => { collaborativeBatchRemoveBlocks, collaborativeBatchToggleBlockEnabled, collaborativeBatchToggleBlockHandles, + collaborativeGroupBlocks, + collaborativeUngroupBlocks, undo, redo, } = useCollaborativeWorkflow() @@ -782,6 +786,21 @@ const WorkflowContent = React.memo(() => { collaborativeBatchToggleBlockHandles(blockIds) }, [contextMenuBlocks, collaborativeBatchToggleBlockHandles]) + const handleContextGroupBlocks = useCallback(() => { + const blockIds = contextMenuBlocks.map((block) => block.id) + if (blockIds.length >= 2) { + collaborativeGroupBlocks(blockIds) + } + }, [contextMenuBlocks, collaborativeGroupBlocks]) + + const handleContextUngroupBlocks = useCallback(() => { + // Find the first block with a groupId and ungroup that entire group + const groupedBlock = contextMenuBlocks.find((block) => block.groupId) + if (groupedBlock?.groupId) { + collaborativeUngroupBlocks(groupedBlock.groupId) + } + }, [contextMenuBlocks, collaborativeUngroupBlocks]) + const handleContextRemoveFromSubflow = useCallback(() => { const blocksToRemove = contextMenuBlocks.filter( (block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel') @@ -2060,16 +2079,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] ) /** @@ -3168,19 +3227,56 @@ 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. * 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) => ({ + const groups = getGroups() + + setNodes((nodes) => { + // First, calculate the base selection + let updatedNodes = nodes.map((n) => ({ ...n, selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id, })) - ) + + // 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 + if (expandedNodeIds.size > selectedNodeIds.size) { + updatedNodes = updatedNodes.map((n) => ({ + ...n, + selected: expandedNodeIds.has(n.id) ? true : n.selected, + })) + } + } + + return updatedNodes + }) }, - [setNodes] + [setNodes, blocks, getGroups] ) /** Handles edge selection with container context tracking and Shift-click multi-selection. */ @@ -3415,6 +3511,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..8bde2d7952 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,84 @@ 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 parentGroupId = group.parentGroupId + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, + payload: { groupId, blockIds, parentGroupId }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + workflowStore.ungroupBlocks(groupId) + + undoRedo.recordUngroupBlocks(groupId, blockIds, parentGroupId) + + 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 +1729,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/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..a6bd24f8b7 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -810,6 +810,89 @@ 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}`) + + 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 || {} + const updatedData = { ...currentData, 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, parentGroupId } = 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}`) + + 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 || {} + let updatedData: Record + + if (parentGroupId) { + // Move to parent group + updatedData = { ...currentData, groupId: parentGroupId } + } else { + // Remove from group entirely + const { groupId: _removed, ...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/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 398c662812..7f5e1398c7 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,135 @@ 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 + + // Remove blocks from any existing groups they might be in + const updatedGroups = { ...currentGroups } + for (const gId of Object.keys(updatedGroups)) { + updatedGroups[gId] = { + ...updatedGroups[gId], + blockIds: updatedGroups[gId].blockIds.filter((bid) => !blockIds.includes(bid)), + } + // Remove empty groups + if (updatedGroups[gId].blockIds.length === 0) { + delete updatedGroups[gId] + } + } + + // Create the new group + updatedGroups[newGroupId] = { + id: newGroupId, + blockIds: [...blockIds], + } + + // Update blocks with the new groupId + const newBlocks = { ...currentBlocks } + for (const blockId of blockIds) { + if (newBlocks[blockId]) { + newBlocks[blockId] = { + ...newBlocks[blockId], + data: { + ...newBlocks[blockId].data, + groupId: 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] + const parentGroupId = group.parentGroupId + + // Remove the group + const updatedGroups = { ...currentGroups } + delete updatedGroups[groupId] + + // Update blocks - remove groupId or assign to parent group + const newBlocks = { ...currentBlocks } + for (const blockId of blockIds) { + if (newBlocks[blockId]) { + const newData = { ...newBlocks[blockId].data } + if (parentGroupId) { + newData.groupId = parentGroupId + } else { + delete newData.groupId + } + newBlocks[blockId] = { + ...newBlocks[blockId], + data: newData, + } + } + } + + // If there's a parent group, add the blocks to it + if (parentGroupId && updatedGroups[parentGroupId]) { + updatedGroups[parentGroupId] = { + ...updatedGroups[parentGroupId], + blockIds: [...updatedGroups[parentGroupId].blockIds, ...blockIds], + } + } + + set({ + blocks: newBlocks, + groups: updatedGroups, + }) + + get().updateLastSaved() + logger.info('Ungrouped blocks', { groupId, blockCount: blockIds.length }) + return blockIds + }, + + getGroupBlockIds: (groupId: string, recursive = false) => { + const groups = get().groups || {} + const group = groups[groupId] + + if (!group) return [] + + if (!recursive) { + return [...group.blockIds] + } + + // Recursively get all block IDs, including from nested groups + const allBlockIds: string[] = [...group.blockIds] + + // Find child groups (groups whose parentGroupId is this group) + for (const g of Object.values(groups)) { + if (g.parentGroupId === groupId) { + allBlockIds.push(...get().getGroupBlockIds(g.id, true)) + } + } + + return allBlockIds + }, + + 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..210aba943e 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -63,6 +63,9 @@ export interface BlockData { // Container node type (for ReactFlow node type determination) type?: string + + // Block group membership + groupId?: string } export interface BlockLayoutState { @@ -144,6 +147,22 @@ 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[] + /** Parent group ID if this group is nested inside another group */ + parentGroupId?: 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 From 8ec067d28094d2cba187e95917b7f88ccf1004e8 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 18:53:19 -0800 Subject: [PATCH 2/5] Ring light --- apps/sim/app/_styles/globals.css | 8 ++ .../components/workflow-block/types.ts | 2 + .../workflow-block/workflow-block.tsx | 4 +- .../w/[workflowId]/hooks/use-block-visual.ts | 13 ++- .../w/[workflowId]/utils/block-ring-utils.ts | 25 ++++- .../[workspaceId]/w/[workflowId]/workflow.tsx | 93 ++++++++++++++++--- 6 files changed, 126 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index b9dcb8c71c..7c5b36436b 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -76,6 +76,14 @@ pointer-events: none; } +/** + * Suppress the default selection ring for grouped selections + * These blocks show a more transparent ring via the component's ring overlay + */ +.react-flow__node.selected > div[data-grouped-selection="true"] > div::after { + box-shadow: none; +} + /** * Color tokens - single source of truth for all colors * Light mode: Warm theme diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts index be830b6a49..49497b0ce6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts @@ -12,6 +12,8 @@ export interface WorkflowBlockProps { isPreview?: boolean /** Whether this block is selected in preview mode */ isPreviewSelected?: boolean + /** Whether this block is selected as part of a group (not directly clicked) */ + isGroupedSelection?: boolean subBlockValues?: Record blockState?: any } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index b98eb7d390..38185e61c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -915,8 +915,10 @@ export const WorkflowBlock = memo(function WorkflowBlock({ const userPermissions = useUserPermissionsContext() const isWorkflowSelector = type === 'workflow' || type === 'workflow_input' + const isGroupedSelection = data.isGroupedSelection ?? false + return ( -
+
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]/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 6023127237..b74eae82f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2589,9 +2589,53 @@ 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) => { + // 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] @@ -2604,7 +2648,7 @@ const WorkflowContent = React.memo(() => { } }) }, - [blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId] + [blocks, setDragStartPosition, getNodes, setNodes, getGroups, potentialParentId, setPotentialParentId] ) /** Handles node drag stop to establish parent-child relationships. */ @@ -3228,6 +3272,7 @@ 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( @@ -3235,12 +3280,29 @@ const WorkflowContent = React.memo(() => { const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey 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) => ({ - ...n, - selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id, - })) + 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)) @@ -3264,12 +3326,19 @@ const WorkflowContent = React.memo(() => { } }) - // Update nodes with expanded selection + // Update nodes with expanded selection, marking group-expanded nodes if (expandedNodeIds.size > selectedNodeIds.size) { - updatedNodes = updatedNodes.map((n) => ({ - ...n, - selected: expandedNodeIds.has(n.id) ? true : n.selected, - })) + 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, + }, + } + }) } } From a3007d8980bf51a5cc51c8fc9d1f6bc4be4ea504 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 19:08:28 -0800 Subject: [PATCH 3/5] Grouping --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 11 ++- apps/sim/socket/database/operations.ts | 35 +++++--- apps/sim/stores/workflows/registry/store.ts | 18 +++- apps/sim/stores/workflows/workflow/store.ts | 89 +++++++++---------- apps/sim/stores/workflows/workflow/types.ts | 4 +- 5 files changed, 91 insertions(+), 66 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index b74eae82f7..ab71501dce 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -794,11 +794,14 @@ const WorkflowContent = React.memo(() => { }, [contextMenuBlocks, collaborativeGroupBlocks]) const handleContextUngroupBlocks = useCallback(() => { - // Find the first block with a groupId and ungroup that entire group + // Find the first block with a groupId const groupedBlock = contextMenuBlocks.find((block) => block.groupId) - if (groupedBlock?.groupId) { - collaborativeUngroupBlocks(groupedBlock.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(() => { diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index a6bd24f8b7..999f9ea2e0 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -819,6 +819,7 @@ async function handleBlocksOperationTx( 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 }) @@ -831,8 +832,13 @@ async function handleBlocksOperationTx( continue } - const currentData = currentBlock?.data || {} - const updatedData = { ...currentData, groupId } + 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) @@ -848,7 +854,7 @@ async function handleBlocksOperationTx( } case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: { - const { groupId, blockIds, parentGroupId } = payload + const { groupId, blockIds } = payload if (!groupId || !Array.isArray(blockIds)) { logger.debug('Invalid payload for ungroup blocks operation') return @@ -856,6 +862,7 @@ async function handleBlocksOperationTx( 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 }) @@ -868,15 +875,23 @@ async function handleBlocksOperationTx( continue } - const currentData = currentBlock?.data || {} - let updatedData: Record + const currentData = (currentBlock?.data || {}) as Record + const currentStack = Array.isArray(currentData.groupStack) ? [...currentData.groupStack] : [] - if (parentGroupId) { - // Move to parent group - updatedData = { ...currentData, groupId: parentGroupId } + // 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 from group entirely - const { groupId: _removed, ...restData } = currentData + // Remove groupId and groupStack if stack is empty + const { groupId: _removed, groupStack: _removedStack, ...restData } = currentData updatedData = restData } 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 7f5e1398c7..e45dd6217b 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -1345,34 +1345,25 @@ export const useWorkflowStore = create()( const currentGroups = get().groups || {} const currentBlocks = get().blocks - // Remove blocks from any existing groups they might be in + // Create the new group with all selected block IDs const updatedGroups = { ...currentGroups } - for (const gId of Object.keys(updatedGroups)) { - updatedGroups[gId] = { - ...updatedGroups[gId], - blockIds: updatedGroups[gId].blockIds.filter((bid) => !blockIds.includes(bid)), - } - // Remove empty groups - if (updatedGroups[gId].blockIds.length === 0) { - delete updatedGroups[gId] - } - } - - // Create the new group updatedGroups[newGroupId] = { id: newGroupId, blockIds: [...blockIds], } - // Update blocks with the new groupId + // 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: { - ...newBlocks[blockId].data, + ...currentData, groupId: newGroupId, + groupStack: [...currentStack, newGroupId], }, } } @@ -1384,7 +1375,10 @@ export const useWorkflowStore = create()( }) get().updateLastSaved() - logger.info('Created block group', { groupId: newGroupId, blockCount: blockIds.length }) + logger.info('Created block group', { + groupId: newGroupId, + blockCount: blockIds.length, + }) return newGroupId }, @@ -1399,68 +1393,65 @@ export const useWorkflowStore = create()( } const blockIds = [...group.blockIds] - const parentGroupId = group.parentGroupId - // Remove the group + // Remove the group from the groups record const updatedGroups = { ...currentGroups } delete updatedGroups[groupId] - // Update blocks - remove groupId or assign to parent group + // Update blocks: pop from groupStack and set groupId to the previous level const newBlocks = { ...currentBlocks } for (const blockId of blockIds) { if (newBlocks[blockId]) { - const newData = { ...newBlocks[blockId].data } - if (parentGroupId) { - newData.groupId = parentGroupId + 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 { - delete newData.groupId + // Remove groupId and groupStack if stack is empty + delete currentData.groupId + delete currentData.groupStack } + newBlocks[blockId] = { ...newBlocks[blockId], - data: newData, + data: currentData, } } } - // If there's a parent group, add the blocks to it - if (parentGroupId && updatedGroups[parentGroupId]) { - updatedGroups[parentGroupId] = { - ...updatedGroups[parentGroupId], - blockIds: [...updatedGroups[parentGroupId].blockIds, ...blockIds], - } - } - set({ blocks: newBlocks, groups: updatedGroups, }) get().updateLastSaved() - logger.info('Ungrouped blocks', { groupId, blockCount: blockIds.length }) + logger.info('Ungrouped blocks', { + groupId, + blockCount: blockIds.length, + }) return blockIds }, - getGroupBlockIds: (groupId: string, recursive = false) => { + getGroupBlockIds: (groupId: string) => { const groups = get().groups || {} const group = groups[groupId] if (!group) return [] - if (!recursive) { - return [...group.blockIds] - } - - // Recursively get all block IDs, including from nested groups - const allBlockIds: string[] = [...group.blockIds] - - // Find child groups (groups whose parentGroupId is this group) - for (const g of Object.values(groups)) { - if (g.parentGroupId === groupId) { - allBlockIds.push(...get().getGroupBlockIds(g.id, true)) - } - } - - return allBlockIds + return [...group.blockIds] }, getGroups: () => { diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 210aba943e..7696a6911f 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -66,6 +66,8 @@ export interface BlockData { // Block group membership groupId?: string + /** Stack of group IDs for hierarchical grouping (oldest to newest) */ + groupStack?: string[] } export interface BlockLayoutState { @@ -159,8 +161,6 @@ export interface BlockGroup { name?: string /** Block IDs that are direct members of this group */ blockIds: string[] - /** Parent group ID if this group is nested inside another group */ - parentGroupId?: string } export interface DragStartPosition { From a45426bb6bef39a65685e58964a064c1d782032d Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 19:38:50 -0800 Subject: [PATCH 4/5] Fix drag --- .../components/note-block/note-block.tsx | 56 ++++- .../components/subflows/subflow-node.tsx | 57 ++++- .../components/workflow-block/types.ts | 2 + .../workflow-block/workflow-block.tsx | 59 +++++- .../[workspaceId]/w/[workflowId]/workflow.tsx | 75 ++++++- apps/sim/hooks/use-collaborative-workflow.ts | 5 +- apps/sim/lib/workflows/autolayout/index.ts | 194 +++++++++++++++++- apps/sim/lib/workflows/autolayout/targeted.ts | 102 ++++++++- apps/sim/lib/workflows/autolayout/utils.ts | 13 +- 9 files changed, 535 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx index 62d44bd053..806fbb0941 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useMemo } from 'react' import ReactMarkdown from 'react-markdown' -import type { NodeProps } from 'reactflow' +import { type NodeProps, useReactFlow } from 'reactflow' import remarkGfm from 'remark-gfm' import { cn } from '@/lib/core/utils/cn' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -10,6 +10,7 @@ import { useBlockDimensions, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions' import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { ActionBar } from '../workflow-block/components' import type { WorkflowBlockProps } from '../workflow-block/types' @@ -198,6 +199,57 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps { + // Only process left mouse button clicks + if (e.button !== 0) return + + const groupId = data.groupId + if (!groupId) return + + const groups = getGroups() + const group = groups[groupId] + if (!group || group.blockIds.length <= 1) return + + const groupBlockIds = new Set(group.blockIds) + const allNodes = getNodes() + + // Check if all group members are already selected + const allSelected = [...groupBlockIds].every((blockId) => + allNodes.find((n) => n.id === blockId && n.selected) + ) + + if (allSelected) return + + // Expand selection to include all group members + setNodes((nodes) => + nodes.map((n) => { + const isInGroup = groupBlockIds.has(n.id) + const isThisBlock = n.id === id + return { + ...n, + selected: isInGroup ? true : n.selected, + data: { + ...n.data, + // Mark as grouped selection if in group but not the directly clicked block + isGroupedSelection: isInGroup && !isThisBlock && !n.selected, + }, + } + }) + ) + }, + [id, data.groupId, getNodes, setNodes, getGroups] + ) + /** * Calculate deterministic dimensions based on content structure. * Uses fixed width and computed height to avoid ResizeObserver jitter. @@ -217,7 +269,7 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps +
) => { - const { getNodes } = useReactFlow() + const { getNodes, setNodes } = useReactFlow() const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow() + const { getGroups } = useWorkflowStore() const blockRef = useRef(null) const currentWorkflow = useCurrentWorkflow() @@ -140,10 +144,57 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps { + // Only process left mouse button clicks + if (e.button !== 0) return + + const groupId = data.groupId + if (!groupId) return + + const groups = getGroups() + const group = groups[groupId] + if (!group || group.blockIds.length <= 1) return + + const groupBlockIds = new Set(group.blockIds) + const allNodes = getNodes() + + // Check if all group members are already selected + const allSelected = [...groupBlockIds].every((blockId) => + allNodes.find((n) => n.id === blockId && n.selected) + ) + + if (allSelected) return + + // Expand selection to include all group members + setNodes((nodes) => + nodes.map((n) => { + const isInGroup = groupBlockIds.has(n.id) + const isThisBlock = n.id === id + return { + ...n, + selected: isInGroup ? true : n.selected, + data: { + ...n.data, + // Mark as grouped selection if in group but not the directly clicked block + isGroupedSelection: isInGroup && !isThisBlock && !n.selected, + }, + } + }) + ) + }, + [id, data.groupId, getNodes, setNodes, getGroups] + ) + return ( <> -
+
setCurrentBlockId(id)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts index 49497b0ce6..2a8ad75047 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts @@ -14,6 +14,8 @@ export interface WorkflowBlockProps { isPreviewSelected?: boolean /** Whether this block is selected as part of a group (not directly clicked) */ isGroupedSelection?: boolean + /** The ID of the group this block belongs to */ + groupId?: string subBlockValues?: Record blockState?: any } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 38185e61c4..f69e17a250 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' -import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow' +import { Handle, type NodeProps, Position, useReactFlow, useUpdateNodeInternals } from 'reactflow' import { Badge, Tooltip } from '@/components/emcn' import { getEnv, isTruthy } from '@/lib/core/config/env' import { cn } from '@/lib/core/utils/cn' @@ -917,8 +917,63 @@ export const WorkflowBlock = memo(function WorkflowBlock({ const isGroupedSelection = data.isGroupedSelection ?? false + // Get React Flow methods for group selection expansion + const { getNodes, setNodes } = useReactFlow() + const { getGroups } = useWorkflowStore() + + /** + * Expands selection to include all group members on mouse down. + * This ensures that when a user starts dragging a block in a group, + * all other blocks in the group are also selected and will move together. + */ + const handleGroupMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only process left mouse button clicks + if (e.button !== 0) return + + const groupId = data.groupId + if (!groupId) return + + const groups = getGroups() + const group = groups[groupId] + if (!group || group.blockIds.length <= 1) return + + const groupBlockIds = new Set(group.blockIds) + const allNodes = getNodes() + + // Check if all group members are already selected + const allSelected = [...groupBlockIds].every((blockId) => + allNodes.find((n) => n.id === blockId && n.selected) + ) + + if (allSelected) return + + // Expand selection to include all group members + setNodes((nodes) => + nodes.map((n) => { + const isInGroup = groupBlockIds.has(n.id) + const isThisBlock = n.id === id + return { + ...n, + selected: isInGroup ? true : n.selected, + data: { + ...n.data, + // Mark as grouped selection if in group but not the directly clicked block + isGroupedSelection: isInGroup && !isThisBlock && !n.selected, + }, + } + }) + ) + }, + [id, data.groupId, getNodes, setNodes, getGroups] + ) + return ( -
+
{ 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 @@ -2436,6 +2437,50 @@ const WorkflowContent = React.memo(() => { // Note: We don't emit position updates during drag to avoid flooding socket events. // The final position is sent in onNodeDragStop for collaborative updates. + // Move all group members together if the dragged node is in a group + const draggedBlockGroupId = blocks[node.id]?.data?.groupId + if (draggedBlockGroupId) { + const groups = getGroups() + const group = groups[draggedBlockGroupId] + if (group && group.blockIds.length > 1) { + // Get the starting position of the dragged node + const startPos = multiNodeDragStartRef.current.get(node.id) + if (startPos) { + // Calculate delta from start position + const deltaX = node.position.x - startPos.x + const deltaY = node.position.y - startPos.y + + // Update positions of all nodes in the group (including dragged node to preserve React Flow's position) + setNodes((nodes) => + nodes.map((n) => { + // For the dragged node, use the position from React Flow's node parameter + if (n.id === node.id) { + return { + ...n, + position: node.position, + } + } + + // Only update nodes in the same group + if (group.blockIds.includes(n.id)) { + const memberStartPos = multiNodeDragStartRef.current.get(n.id) + if (memberStartPos) { + return { + ...n, + position: { + x: memberStartPos.x + deltaX, + y: memberStartPos.y + deltaY, + }, + } + } + } + return n + }) + ) + } + } + } + // Get the current parent ID of the node being dragged const currentParentId = blocks[node.id]?.data?.parentId || null @@ -2568,11 +2613,13 @@ const WorkflowContent = React.memo(() => { }, [ getNodes, + setNodes, potentialParentId, blocks, getNodeAbsolutePosition, getNodeDepth, updateContainerDimensionsDuringDrag, + getGroups, ] ) @@ -2631,6 +2678,8 @@ const WorkflowContent = React.memo(() => { // 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]) { @@ -2661,9 +2710,31 @@ const WorkflowContent = React.memo(() => { // 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 + const draggedBlockGroupId = blocks[node.id]?.data?.groupId + 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) collaborativeBatchUpdatePositions(positionUpdates, { diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 8bde2d7952..b259c520b3 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -1666,7 +1666,6 @@ export function useCollaborativeWorkflow() { } const blockIds = [...group.blockIds] - const parentGroupId = group.parentGroupId const operationId = crypto.randomUUID() @@ -1675,7 +1674,7 @@ export function useCollaborativeWorkflow() { operation: { operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS, target: OPERATION_TARGETS.BLOCKS, - payload: { groupId, blockIds, parentGroupId }, + payload: { groupId, blockIds }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', @@ -1683,7 +1682,7 @@ export function useCollaborativeWorkflow() { workflowStore.ungroupBlocks(groupId) - undoRedo.recordUngroupBlocks(groupId, blockIds, parentGroupId) + undoRedo.recordUngroupBlocks(groupId, blockIds) logger.info('Ungrouped blocks collaboratively', { groupId, blockCount: blockIds.length }) return blockIds 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 } /** From 66d19c00db9460d12b4d4b790da8ff17ea4484c3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 20:54:04 -0800 Subject: [PATCH 5/5] Stuff --- .../components/note-block/note-block.tsx | 8 +- .../components/action-bar/action-bar.tsx | 2 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 188 +++++++++++++----- 3 files changed, 146 insertions(+), 52 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx index 806fbb0941..03728f829a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx @@ -268,8 +268,14 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps +
{ 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) @@ -789,9 +797,20 @@ const WorkflowContent = React.memo(() => { 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]) + }, [contextMenuBlocks, collaborativeGroupBlocks, addNotification]) const handleContextUngroupBlocks = useCallback(() => { // Find the first block with a groupId @@ -2437,50 +2456,6 @@ const WorkflowContent = React.memo(() => { // Note: We don't emit position updates during drag to avoid flooding socket events. // The final position is sent in onNodeDragStop for collaborative updates. - // Move all group members together if the dragged node is in a group - const draggedBlockGroupId = blocks[node.id]?.data?.groupId - if (draggedBlockGroupId) { - const groups = getGroups() - const group = groups[draggedBlockGroupId] - if (group && group.blockIds.length > 1) { - // Get the starting position of the dragged node - const startPos = multiNodeDragStartRef.current.get(node.id) - if (startPos) { - // Calculate delta from start position - const deltaX = node.position.x - startPos.x - const deltaY = node.position.y - startPos.y - - // Update positions of all nodes in the group (including dragged node to preserve React Flow's position) - setNodes((nodes) => - nodes.map((n) => { - // For the dragged node, use the position from React Flow's node parameter - if (n.id === node.id) { - return { - ...n, - position: node.position, - } - } - - // Only update nodes in the same group - if (group.blockIds.includes(n.id)) { - const memberStartPos = multiNodeDragStartRef.current.get(n.id) - if (memberStartPos) { - return { - ...n, - position: { - x: memberStartPos.x + deltaX, - y: memberStartPos.y + deltaY, - }, - } - } - } - return n - }) - ) - } - } - } - // Get the current parent ID of the node being dragged const currentParentId = blocks[node.id]?.data?.parentId || null @@ -2613,13 +2588,11 @@ const WorkflowContent = React.memo(() => { }, [ getNodes, - setNodes, potentialParentId, blocks, getNodeAbsolutePosition, getNodeDepth, updateContainerDimensionsDuringDrag, - getGroups, ] ) @@ -2699,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, setNodes, getGroups, potentialParentId, setPotentialParentId] + [blocks, setDragStartPosition, getNodes, setNodes, getGroups, potentialParentId, setPotentialParentId, getNodeAbsolutePosition] ) /** Handles node drag stop to establish parent-child relationships. */ @@ -2708,12 +2736,55 @@ 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() let selectedNodes = allNodes.filter((n) => n.selected) // If the dragged node is in a group, include all group members - const draggedBlockGroupId = blocks[node.id]?.data?.groupId if (draggedBlockGroupId) { const groups = getGroups() const group = groups[draggedBlockGroupId] @@ -2736,7 +2807,22 @@ const WorkflowContent = React.memo(() => { // 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, }) @@ -3020,6 +3106,7 @@ const WorkflowContent = React.memo(() => { }, [ getNodes, + setNodes, dragStartParentId, potentialParentId, updateNodeParent, @@ -3038,6 +3125,7 @@ const WorkflowContent = React.memo(() => { activeWorkflowId, collaborativeBatchUpdatePositions, collaborativeBatchUpdateParent, + getGroups, ] )