diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx index c2e2880769d..68f179ffd9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx @@ -168,7 +168,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps )} - {/* Header Section — only interactive area for dragging */} + {/* Header Section */}
setCurrentBlockId(id)} className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing' @@ -198,14 +198,15 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps {/* - * Subflow body background. Uses pointer-events: none so that edges rendered - * inside the subflow remain clickable. The subflow node wrapper also has - * pointer-events: none (set in workflow.tsx), so body-area clicks pass - * through to the pane. Subflow selection is done via the header above. + * Subflow body background. Captures clicks to select the subflow in the + * panel editor, matching the header click behavior. Child nodes and edges + * are rendered as sibling divs at the viewport level by ReactFlow (not as + * DOM children), so enabling pointer events here doesn't block them. */}
setCurrentBlockId(id)} /> {!isPreview && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index 3306fac0ffb..0bd13f7001c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -221,3 +221,68 @@ export function resolveParentChildSelectionConflicts( return hasConflict ? resolved : nodes } + +export function getNodeSelectionContextId( + node: Pick, + blocks: Record +): string | null { + return node.parentId || blocks[node.id]?.data?.parentId || null +} + +export function getEdgeSelectionContextId( + edge: Pick, + nodes: Array>, + blocks: Record +): string | null { + const sourceNode = nodes.find((node) => node.id === edge.source) + const targetNode = nodes.find((node) => node.id === edge.target) + const sourceContextId = sourceNode ? getNodeSelectionContextId(sourceNode, blocks) : null + const targetContextId = targetNode ? getNodeSelectionContextId(targetNode, blocks) : null + if (sourceContextId) return sourceContextId + if (targetContextId) return targetContextId + return null +} + +export function resolveSelectionContextConflicts( + nodes: Node[], + blocks: Record, + preferredContextId?: string | null +): Node[] { + const selectedNodes = nodes.filter((node) => node.selected) + if (selectedNodes.length <= 1) return nodes + + const allowedContextId = + preferredContextId !== undefined + ? preferredContextId + : getNodeSelectionContextId(selectedNodes[0], blocks) + let hasConflict = false + + const resolved = nodes.map((node) => { + if (!node.selected) return node + const contextId = getNodeSelectionContextId(node, blocks) + if (contextId !== allowedContextId) { + hasConflict = true + return { ...node, selected: false } + } + return node + }) + + return hasConflict ? resolved : nodes +} + +export function resolveSelectionConflicts( + nodes: Node[], + blocks: Record, + preferredNodeId?: string +): Node[] { + const afterParentChild = resolveParentChildSelectionConflicts(nodes, blocks) + + const preferredContextId = + preferredNodeId !== undefined + ? afterParentChild.find((n) => n.id === preferredNodeId && n.selected) + ? getNodeSelectionContextId(afterParentChild.find((n) => n.id === preferredNodeId)!, blocks) + : undefined + : undefined + + return resolveSelectionContextConflicts(afterParentChild, blocks, preferredContextId) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 461de618c53..59e56a16b6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -59,11 +59,13 @@ import { filterProtectedBlocks, getClampedPositionForNode, getDescendantBlockIds, + getEdgeSelectionContextId, + getNodeSelectionContextId, getWorkflowLockToggleIds, isBlockProtected, isEdgeProtected, isInEditableElement, - resolveParentChildSelectionConflicts, + resolveSelectionConflicts, validateTriggerPaste, } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useSocket } from '@/app/workspace/providers/socket-provider' @@ -168,16 +170,17 @@ function mapEdgesByNode(edges: Edge[], nodeIds: Set): Map 1 && currentBlockId) { - clearCurrentBlock() + if (selectedIds.length === 0) { + if (currentBlockId) clearCurrentBlock() + } else { + const lastSelectedId = selectedIds[selectedIds.length - 1] + if (lastSelectedId !== currentBlockId) { + setCurrentBlockId(lastSelectedId) + } } } @@ -246,7 +249,6 @@ const WorkflowContent = React.memo( const [selectedEdges, setSelectedEdges] = useState(new Map()) const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false) const canvasContainerRef = useRef(null) - const selectedIdsRef = useRef(null) const embeddedFitFrameRef = useRef(null) const hasCompletedInitialEmbeddedFitRef = useRef(false) const canvasMode = useCanvasModeStore((state) => state.mode) @@ -2477,6 +2479,16 @@ const WorkflowContent = React.memo( // Local state for nodes - allows smooth drag without store updates on every frame const [displayNodes, setDisplayNodes] = useState([]) + const selectedNodeIds = useMemo( + () => displayNodes.filter((node) => node.selected).map((node) => node.id), + [displayNodes] + ) + const selectedNodeIdsKey = selectedNodeIds.join(',') + + useEffect(() => { + syncPanelWithSelection(selectedNodeIds) + }, [selectedNodeIdsKey]) + useEffect(() => { // Check for pending selection (from paste/duplicate), otherwise preserve existing selection if (pendingSelection && pendingSelection.length > 0) { @@ -2488,10 +2500,8 @@ const WorkflowContent = React.memo( ...node, selected: pendingSet.has(node.id), })) - const resolved = resolveParentChildSelectionConflicts(withSelection, blocks) + const resolved = resolveSelectionConflicts(withSelection, blocks) setDisplayNodes(resolved) - const selectedIds = resolved.filter((node) => node.selected).map((node) => node.id) - syncPanelWithSelection(selectedIds) return } @@ -2709,19 +2719,20 @@ const WorkflowContent = React.memo( /** Handles node changes - applies changes and resolves parent-child selection conflicts. */ const onNodesChange = useCallback( (changes: NodeChange[]) => { - selectedIdsRef.current = null - setDisplayNodes((nds) => { - const updated = applyNodeChanges(changes, nds) - const hasSelectionChange = changes.some((c) => c.type === 'select') + const hasSelectionChange = changes.some((c) => c.type === 'select') + setDisplayNodes((currentNodes) => { + const updated = applyNodeChanges(changes, currentNodes) if (!hasSelectionChange) return updated - const resolved = resolveParentChildSelectionConflicts(updated, blocks) - selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id) - return resolved + + const preferredNodeId = [...changes] + .reverse() + .find( + (change): change is NodeChange & { id: string; selected: boolean } => + change.type === 'select' && 'selected' in change && change.selected === true + )?.id + + return resolveSelectionConflicts(updated, blocks, preferredNodeId) }) - const selectedIds = selectedIdsRef.current as string[] | null - if (selectedIds !== null) { - syncPanelWithSelection(selectedIds) - } // Handle position changes (e.g., from keyboard arrow key movement) // Update container dimensions when child nodes are moved and persist to backend @@ -3160,7 +3171,10 @@ const WorkflowContent = React.memo( parentId: currentParentId, }) - // Capture all selected nodes' positions for multi-node undo/redo + // Capture all selected nodes' positions for multi-node undo/redo. + // Also include the dragged node itself — during shift+click+drag, ReactFlow + // may have toggled (deselected) the node before drag starts, so it might not + // appear in the selected set yet. const allNodes = getNodes() const selectedNodes = allNodes.filter((n) => n.selected) multiNodeDragStartRef.current.clear() @@ -3174,6 +3188,33 @@ const WorkflowContent = React.memo( }) } }) + if (!multiNodeDragStartRef.current.has(node.id)) { + multiNodeDragStartRef.current.set(node.id, { + x: node.position.x, + y: node.position.y, + parentId: currentParentId ?? undefined, + }) + } + + // When shift+clicking an already-selected node, ReactFlow toggles (deselects) + // it via onNodesChange before drag starts. Re-select the dragged node so all + // previously selected nodes move together as a group — but only if the + // deselection wasn't from a parent-child conflict (e.g. dragging a child + // when its parent subflow is selected). + const draggedNodeInSelected = allNodes.find((n) => n.id === node.id) + if (draggedNodeInSelected && !draggedNodeInSelected.selected && selectedNodes.length > 0) { + const draggedParentId = blocks[node.id]?.data?.parentId + const parentIsSelected = + draggedParentId && selectedNodes.some((n) => n.id === draggedParentId) + const contextMismatch = + getNodeSelectionContextId(draggedNodeInSelected, blocks) !== + getNodeSelectionContextId(selectedNodes[0], blocks) + if (!parentIsSelected && !contextMismatch) { + setDisplayNodes((currentNodes) => + currentNodes.map((n) => (n.id === node.id ? { ...n, selected: true } : n)) + ) + } + } }, [blocks, setDragStartPosition, getNodes, setPotentialParentId] ) @@ -3453,7 +3494,7 @@ const WorkflowContent = React.memo( }) // Apply visual deselection of children - setDisplayNodes((allNodes) => resolveParentChildSelectionConflicts(allNodes, blocks)) + setDisplayNodes((allNodes) => resolveSelectionConflicts(allNodes, blocks)) }, [blocks] ) @@ -3604,19 +3645,25 @@ const WorkflowContent = React.memo( /** * Handles node click to select the node in ReactFlow. - * Parent-child conflict resolution happens automatically in onNodesChange. + * Uses the controlled display node state so parent-child conflicts are resolved + * consistently for click, shift-click, and marquee selection. */ const handleNodeClick = useCallback( (event: React.MouseEvent, node: Node) => { const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey - setNodes((nodes) => - nodes.map((n) => ({ - ...n, - selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id, + setDisplayNodes((currentNodes) => { + const updated = currentNodes.map((currentNode) => ({ + ...currentNode, + selected: isMultiSelect + ? currentNode.id === node.id + ? true + : currentNode.selected + : currentNode.id === node.id, })) - ) + return resolveSelectionConflicts(updated, blocks, isMultiSelect ? node.id : undefined) + }) }, - [setNodes] + [blocks] ) /** Handles edge selection with container context tracking and Shift-click multi-selection. */ @@ -3624,16 +3671,10 @@ const WorkflowContent = React.memo( (event: React.MouseEvent, edge: any) => { event.stopPropagation() // Prevent bubbling - // Determine if edge is inside a loop by checking its source/target nodes - const sourceNode = getNodes().find((n) => n.id === edge.source) - const targetNode = getNodes().find((n) => n.id === edge.target) - - // An edge is inside a loop if either source or target has a parent - // If source and target have different parents, prioritize source's parent - const parentLoopId = sourceNode?.parentId || targetNode?.parentId - - // Create a unique identifier that combines edge ID and parent context - const contextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}` + const contextId = `${edge.id}${(() => { + const selectionContextId = getEdgeSelectionContextId(edge, getNodes(), blocks) + return selectionContextId ? `-${selectionContextId}` : '' + })()}` if (event.shiftKey) { // Shift-click: toggle edge in selection @@ -3651,7 +3692,7 @@ const WorkflowContent = React.memo( setSelectedEdges(new Map([[contextId, edge.id]])) } }, - [getNodes] + [blocks, getNodes] ) /** Stable delete handler to avoid creating new function references per edge. */ diff --git a/apps/sim/executor/dag/builder.test.ts b/apps/sim/executor/dag/builder.test.ts index cf3833767ba..9be90e75f85 100644 --- a/apps/sim/executor/dag/builder.test.ts +++ b/apps/sim/executor/dag/builder.test.ts @@ -92,6 +92,57 @@ describe('DAGBuilder disabled subflow validation', () => { // Should not throw - loop is effectively disabled since all inner blocks are disabled expect(() => builder.build(workflow)).not.toThrow() }) + + it('does not mutate serialized loop config nodes during DAG build', () => { + const workflow: SerializedWorkflow = { + version: '1', + blocks: [ + createBlock('start', BlockType.STARTER), + createBlock('loop-1', BlockType.LOOP), + { ...createBlock('inner-block', BlockType.FUNCTION), enabled: false }, + ], + connections: [{ source: 'start', target: 'loop-1' }], + loops: { + 'loop-1': { + id: 'loop-1', + nodes: ['inner-block'], + iterations: 3, + }, + }, + parallels: {}, + } + + const builder = new DAGBuilder() + builder.build(workflow) + + expect(workflow.loops?.['loop-1']?.nodes).toEqual(['inner-block']) + }) + + it('does not mutate serialized parallel config nodes during DAG build', () => { + const workflow: SerializedWorkflow = { + version: '1', + blocks: [ + createBlock('start', BlockType.STARTER), + createBlock('parallel-1', BlockType.PARALLEL), + { ...createBlock('inner-block', BlockType.FUNCTION), enabled: false }, + ], + connections: [{ source: 'start', target: 'parallel-1' }], + loops: {}, + parallels: { + 'parallel-1': { + id: 'parallel-1', + nodes: ['inner-block'], + count: 2, + parallelType: 'count', + }, + }, + } + + const builder = new DAGBuilder() + builder.build(workflow) + + expect(workflow.parallels?.['parallel-1']?.nodes).toEqual(['inner-block']) + }) }) describe('DAGBuilder nested parallel support', () => { diff --git a/apps/sim/executor/dag/builder.ts b/apps/sim/executor/dag/builder.ts index 90647075794..6d8ee819ea7 100644 --- a/apps/sim/executor/dag/builder.ts +++ b/apps/sim/executor/dag/builder.ts @@ -113,13 +113,19 @@ export class DAGBuilder { private initializeConfigs(workflow: SerializedWorkflow, dag: DAG): void { if (workflow.loops) { for (const [loopId, loopConfig] of Object.entries(workflow.loops)) { - dag.loopConfigs.set(loopId, loopConfig) + dag.loopConfigs.set(loopId, { + ...loopConfig, + nodes: [...(loopConfig.nodes ?? [])], + }) } } if (workflow.parallels) { for (const [parallelId, parallelConfig] of Object.entries(workflow.parallels)) { - dag.parallelConfigs.set(parallelId, parallelConfig) + dag.parallelConfigs.set(parallelId, { + ...parallelConfig, + nodes: [...(parallelConfig.nodes ?? [])], + }) } } } @@ -150,9 +156,7 @@ export class DAGBuilder { if (!sentinelStartNode) return if (!nodes || nodes.length === 0) { - throw new Error( - `${type} has no blocks inside. Add at least one block to the ${type.toLowerCase()}.` - ) + return } const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) => diff --git a/apps/sim/executor/dag/construction/loops.ts b/apps/sim/executor/dag/construction/loops.ts index 2578fba82cf..cb9715c90d8 100644 --- a/apps/sim/executor/dag/construction/loops.ts +++ b/apps/sim/executor/dag/construction/loops.ts @@ -8,24 +8,21 @@ const logger = createLogger('LoopConstructor') export class LoopConstructor { execute(dag: DAG, reachableBlocks: Set): void { for (const [loopId, loopConfig] of dag.loopConfigs) { - const loopNodes = loopConfig.nodes - - if (loopNodes.length === 0) { + if (!reachableBlocks.has(loopId)) { continue } - if (!this.hasReachableNodes(loopNodes, reachableBlocks)) { - continue + const loopNodes = loopConfig.nodes + const hasReachableChildren = loopNodes.some((nodeId) => reachableBlocks.has(nodeId)) + + if (!hasReachableChildren) { + loopConfig.nodes = [] } this.createSentinelPair(dag, loopId) } } - private hasReachableNodes(loopNodes: string[], reachableBlocks: Set): boolean { - return loopNodes.some((nodeId) => reachableBlocks.has(nodeId)) - } - private createSentinelPair(dag: DAG, loopId: string): void { const startId = buildSentinelStartId(loopId) const endId = buildSentinelEndId(loopId) diff --git a/apps/sim/executor/dag/construction/parallels.ts b/apps/sim/executor/dag/construction/parallels.ts index cbb55a7a08f..ac4b6ea5d84 100644 --- a/apps/sim/executor/dag/construction/parallels.ts +++ b/apps/sim/executor/dag/construction/parallels.ts @@ -11,24 +11,21 @@ const logger = createLogger('ParallelConstructor') export class ParallelConstructor { execute(dag: DAG, reachableBlocks: Set): void { for (const [parallelId, parallelConfig] of dag.parallelConfigs) { - const parallelNodes = parallelConfig.nodes - - if (parallelNodes.length === 0) { + if (!reachableBlocks.has(parallelId)) { continue } - if (!this.hasReachableNodes(parallelNodes, reachableBlocks)) { - continue + const parallelNodes = parallelConfig.nodes + const hasReachableChildren = parallelNodes.some((nodeId) => reachableBlocks.has(nodeId)) + + if (!hasReachableChildren) { + parallelConfig.nodes = [] } this.createSentinelPair(dag, parallelId) } } - private hasReachableNodes(parallelNodes: string[], reachableBlocks: Set): boolean { - return parallelNodes.some((nodeId) => reachableBlocks.has(nodeId)) - } - private createSentinelPair(dag: DAG, parallelId: string): void { const startId = buildParallelSentinelStartId(parallelId) const endId = buildParallelSentinelEndId(parallelId) diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts index 0ea42a137e8..c8b4ea4c9a9 100644 --- a/apps/sim/executor/orchestrators/loop.ts +++ b/apps/sim/executor/orchestrators/loop.ts @@ -56,6 +56,29 @@ export class LoopOrchestrator { if (!loopConfig) { throw new Error(`Loop config not found: ${loopId}`) } + + if (loopConfig.nodes.length === 0) { + const errorMessage = + 'Loop has no executable blocks inside. Add or enable at least one block in the loop.' + const loopType = loopConfig.loopType || 'for' + logger.error(errorMessage, { loopId }) + await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {}) + const errorScope: LoopScope = { + iteration: 0, + maxIterations: 0, + loopType, + currentIterationOutputs: new Map(), + allIterationOutputs: [], + condition: 'false', + validationError: errorMessage, + } + if (!ctx.loopExecutions) { + ctx.loopExecutions = new Map() + } + ctx.loopExecutions.set(loopId, errorScope) + throw new Error(errorMessage) + } + const scope: LoopScope = { iteration: 0, currentIterationOutputs: new Map(), @@ -93,6 +116,24 @@ export class LoopOrchestrator { case 'forEach': { scope.loopType = 'forEach' + if ( + loopConfig.forEachItems === undefined || + loopConfig.forEachItems === null || + loopConfig.forEachItems === '' + ) { + const errorMessage = + 'ForEach loop collection is empty. Provide an array or a reference that resolves to a collection.' + logger.error(errorMessage, { loopId }) + await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, { + forEachItems: loopConfig.forEachItems, + }) + scope.items = [] + scope.maxIterations = 0 + scope.validationError = errorMessage + scope.condition = buildLoopIndexCondition(0) + ctx.loopExecutions?.set(loopId, scope) + throw new Error(errorMessage) + } let items: any[] try { items = resolveArrayInput(ctx, loopConfig.forEachItems, this.resolver) diff --git a/apps/sim/executor/orchestrators/parallel.ts b/apps/sim/executor/orchestrators/parallel.ts index 23f7dede964..a8896f1362f 100644 --- a/apps/sim/executor/orchestrators/parallel.ts +++ b/apps/sim/executor/orchestrators/parallel.ts @@ -57,6 +57,15 @@ export class ParallelOrchestrator { throw new Error(`Parallel config not found: ${parallelId}`) } + if (terminalNodesCount === 0 || parallelConfig.nodes.length === 0) { + const errorMessage = + 'Parallel has no executable blocks inside. Add or enable at least one block in the parallel.' + logger.error(errorMessage, { parallelId }) + await this.addParallelErrorLog(ctx, parallelId, errorMessage, {}) + this.setErrorScope(ctx, parallelId, errorMessage) + throw new Error(errorMessage) + } + let items: any[] | undefined let branchCount: number let isEmpty = false @@ -67,7 +76,10 @@ export class ParallelOrchestrator { items = resolved.items isEmpty = resolved.isEmpty ?? false } catch (error) { - const errorMessage = `Parallel Items did not resolve: ${error instanceof Error ? error.message : String(error)}` + const baseErrorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = baseErrorMessage.startsWith('Parallel collection distribution is empty') + ? baseErrorMessage + : `Parallel Items did not resolve: ${baseErrorMessage}` logger.error(errorMessage, { parallelId, distribution: parallelConfig.distribution }) await this.addParallelErrorLog(ctx, parallelId, errorMessage, { distribution: parallelConfig.distribution, @@ -258,7 +270,9 @@ export class ParallelOrchestrator { config.distribution === null || config.distribution === '' ) { - return [] + throw new Error( + 'Parallel collection distribution is empty. Provide an array or a reference that resolves to a collection.' + ) } return resolveArrayInput(ctx, config.distribution, this.resolver) }