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