Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
)}

{/* Header Section — only interactive area for dragging */}
{/* Header Section */}
<div
onClick={() => 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'
Expand Down Expand Up @@ -198,14 +198,15 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
</div>

{/*
* 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.
*/}
<div
className='absolute inset-0 top-[44px] rounded-b-[8px]'
style={{ pointerEvents: 'none' }}
className='workflow-drag-handle absolute inset-0 top-[44px] cursor-grab rounded-b-[8px] [&:active]:cursor-grabbing'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
onClick={() => setCurrentBlockId(id)}
Comment thread
icecrasher321 marked this conversation as resolved.
/>

{!isPreview && (
Expand Down
21 changes: 16 additions & 5 deletions apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3604,19 +3604,30 @@ 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) => ({
selectedIdsRef.current = null
setDisplayNodes((nodes) => {
const updated = nodes.map((n) => ({
...n,
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
}))
)
const resolved = resolveParentChildSelectionConflicts(updated, blocks)
selectedIdsRef.current = resolved
.filter((selectedNode) => selectedNode.selected)
.map((selectedNode) => selectedNode.id)
return resolved
})
const selectedIds = selectedIdsRef.current as string[] | null
if (selectedIds !== null) {
syncPanelWithSelection(selectedIds)
}
Comment thread
icecrasher321 marked this conversation as resolved.
Outdated
},
[setNodes]
[blocks]
)

/** Handles edge selection with container context tracking and Shift-click multi-selection. */
Expand Down
4 changes: 1 addition & 3 deletions apps/sim/executor/dag/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,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) =>
Expand Down
15 changes: 6 additions & 9 deletions apps/sim/executor/dag/construction/loops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,21 @@ const logger = createLogger('LoopConstructor')
export class LoopConstructor {
execute(dag: DAG, reachableBlocks: Set<string>): 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 = []
}
Comment thread
icecrasher321 marked this conversation as resolved.

this.createSentinelPair(dag, loopId)
}
}

private hasReachableNodes(loopNodes: string[], reachableBlocks: Set<string>): boolean {
return loopNodes.some((nodeId) => reachableBlocks.has(nodeId))
}

private createSentinelPair(dag: DAG, loopId: string): void {
const startId = buildSentinelStartId(loopId)
const endId = buildSentinelEndId(loopId)
Expand Down
15 changes: 6 additions & 9 deletions apps/sim/executor/dag/construction/parallels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,21 @@ const logger = createLogger('ParallelConstructor')
export class ParallelConstructor {
execute(dag: DAG, reachableBlocks: Set<string>): 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 = []
}
Comment thread
icecrasher321 marked this conversation as resolved.

this.createSentinelPair(dag, parallelId)
}
}

private hasReachableNodes(parallelNodes: string[], reachableBlocks: Set<string>): boolean {
return parallelNodes.some((nodeId) => reachableBlocks.has(nodeId))
}

private createSentinelPair(dag: DAG, parallelId: string): void {
const startId = buildParallelSentinelStartId(parallelId)
const endId = buildParallelSentinelEndId(parallelId)
Expand Down
41 changes: 41 additions & 0 deletions apps/sim/executor/orchestrators/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/executor/orchestrators/parallel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -258,7 +267,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.'
)
}
Comment thread
icecrasher321 marked this conversation as resolved.
return resolveArrayInput(ctx, config.distribution, this.resolver)
}
Expand Down
Loading