Skip to content

Commit 27ed713

Browse files
waleedlatif1claude
andcommitted
fix(subflows): enable nested subflow interaction and execution highlighting
Remove !important z-index overrides that prevented nested subflows from being grabbed/dragged independently. Z-index is now managed by ReactFlow's elevateNodesOnSelect and per-node zIndex: depth props. Also adds execution status highlighting for nested subflows in both canvas and snapshot preview. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ea4fcdc commit 27ed713

File tree

7 files changed

+109
-46
lines changed

7 files changed

+109
-46
lines changed

apps/sim/app/_styles/globals.css

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -833,15 +833,7 @@ input[type="search"]::-ms-clear {
833833
animation: growShrink 1.5s infinite ease-in-out;
834834
}
835835

836-
/* Subflow node z-index and drag-over styles */
837-
.workflow-container .react-flow__node-subflowNode {
838-
z-index: -1 !important;
839-
}
840-
841-
.workflow-container .react-flow__node-subflowNode:has([data-subflow-selected="true"]) {
842-
z-index: 10 !important;
843-
}
844-
836+
/* Subflow node drag-over styles */
845837
.loop-node-drag-over,
846838
.parallel-node-drag-over {
847839
box-shadow: 0 0 0 1.75px var(--brand-secondary) !important;

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,8 +1240,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
12401240

12411241
const parallelBlockGroups: BlockTagGroup[] = []
12421242
const ancestorParallelIds = new Set<string>()
1243+
const visitedParallelTargets = new Set<string>()
12431244

12441245
const findAncestorParallels = (targetId: string) => {
1246+
if (visitedParallelTargets.has(targetId)) return
1247+
visitedParallelTargets.add(targetId)
1248+
12451249
for (const [parallelId, parallel] of Object.entries(parallels || {})) {
12461250
if (parallel.nodes.includes(targetId) && !ancestorParallelIds.has(parallelId)) {
12471251
ancestorParallelIds.add(parallelId)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
88
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
99
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
1010
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
11+
import { useLastRunPath } from '@/stores/execution'
1112
import { usePanelEditorStore } from '@/stores/panel'
1213

1314
/**
@@ -23,6 +24,8 @@ export interface SubflowNodeData {
2324
isPreviewSelected?: boolean
2425
kind: 'loop' | 'parallel'
2526
name?: string
27+
/** Execution status passed by preview/snapshot views */
28+
executionStatus?: 'success' | 'error' | 'not-executed'
2629
}
2730

2831
/**
@@ -56,6 +59,15 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
5659

5760
const isPreviewSelected = data?.isPreviewSelected || false
5861

62+
const lastRunPath = useLastRunPath()
63+
const executionStatus = data.executionStatus
64+
const runPathStatus: 'success' | 'error' | undefined =
65+
executionStatus === 'success' || executionStatus === 'error'
66+
? executionStatus
67+
: isPreview
68+
? undefined
69+
: lastRunPath.get(id)
70+
5971
/**
6072
* Calculate the nesting level of this subflow node based on its parent hierarchy.
6173
* Used to apply appropriate styling for nested containers.
@@ -105,32 +117,57 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
105117
* Determine the ring styling based on subflow state priority:
106118
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
107119
* 2. Diff status (version comparison) - green/orange ring
120+
* 3. Run path status (execution result) - green/red ring
108121
*/
109122
const isSelected = !isPreview && selected
110123
const hasRing =
111-
isFocused || isSelected || isPreviewSelected || diffStatus === 'new' || diffStatus === 'edited'
112-
const ringStyles = cn(
113-
hasRing && 'ring-[1.75px]',
114-
(isFocused || isSelected || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
115-
diffStatus === 'new' && 'ring-[var(--brand-tertiary-2)]',
116-
diffStatus === 'edited' && 'ring-[var(--warning)]'
117-
)
124+
isFocused ||
125+
isSelected ||
126+
isPreviewSelected ||
127+
diffStatus === 'new' ||
128+
diffStatus === 'edited' ||
129+
!!runPathStatus
130+
/**
131+
* Compute the outline color for the subflow ring.
132+
* Uses CSS outline instead of box-shadow ring because in ReactFlow v11,
133+
* child nodes are DOM children of parent nodes and paint over the parent's
134+
* internal ring overlay. Outline renders on the element's own compositing
135+
* layer, so it stays visible above nested child nodes.
136+
*/
137+
const outlineColor = hasRing
138+
? isFocused || isSelected || isPreviewSelected
139+
? 'var(--brand-secondary)'
140+
: diffStatus === 'new'
141+
? 'var(--brand-tertiary-2)'
142+
: diffStatus === 'edited'
143+
? 'var(--warning)'
144+
: runPathStatus === 'success'
145+
? executionStatus
146+
? 'var(--brand-tertiary-2)'
147+
: 'var(--border-success)'
148+
: runPathStatus === 'error'
149+
? 'var(--text-error)'
150+
: undefined
151+
: undefined
118152

119153
return (
120154
<div className='group relative'>
121155
<div
122156
ref={blockRef}
123-
onClick={() => setCurrentBlockId(id)}
124157
className={cn(
125-
'workflow-drag-handle relative cursor-grab select-none rounded-[8px] border border-[var(--border-1)] [&:active]:cursor-grabbing',
126-
'transition-block-bg transition-ring'
158+
'relative select-none rounded-[8px] border border-[var(--border-1)]',
159+
'transition-block-bg'
127160
)}
128161
style={{
129162
width: data.width || 500,
130163
height: data.height || 300,
131164
position: 'relative',
132165
overflow: 'visible',
133-
pointerEvents: isPreview ? 'none' : 'all',
166+
pointerEvents: 'none',
167+
...(outlineColor && {
168+
outline: `1.75px solid ${outlineColor}`,
169+
outlineOffset: '-1px',
170+
}),
134171
}}
135172
data-node-id={id}
136173
data-type='subflowNode'
@@ -141,11 +178,13 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
141178
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
142179
)}
143180

144-
{/* Header Section */}
181+
{/* Header Section — only interactive area for dragging */}
145182
<div
183+
onClick={() => setCurrentBlockId(id)}
146184
className={cn(
147-
'flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'
185+
'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'
148186
)}
187+
style={{ pointerEvents: 'auto' }}
149188
>
150189
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
151190
<div
@@ -182,7 +221,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
182221
data-dragarea='true'
183222
style={{
184223
position: 'relative',
185-
pointerEvents: isPreview ? 'none' : 'auto',
224+
pointerEvents: 'none',
186225
}}
187226
>
188227
{/* Subflow Start */}
@@ -232,12 +271,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
232271
}}
233272
id={endHandleId}
234273
/>
235-
236-
{hasRing && (
237-
<div
238-
className={cn('pointer-events-none absolute inset-0 z-40 rounded-[8px]', ringStyles)}
239-
/>
240-
)}
241274
</div>
242275
</div>
243276
)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
} from '@/executor/types'
3333
import { hasExecutionResult } from '@/executor/utils/errors'
3434
import { coerceValue } from '@/executor/utils/start-block'
35+
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
3536
import { subscriptionKeys } from '@/hooks/queries/subscription'
3637
import { useExecutionStream } from '@/hooks/use-execution-stream'
3738
import { WorkflowValidationError } from '@/serializer'
@@ -486,14 +487,23 @@ export function useWorkflowExecution() {
486487
if (isStaleExecution()) return
487488
updateActiveBlocks(data.blockId, false)
488489
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'success')
489-
490490
executedBlockIds.add(data.blockId)
491491
accumulatedBlockStates.set(data.blockId, {
492492
output: data.output,
493493
executed: true,
494494
executionTime: data.durationMs,
495495
})
496496

497+
// For nested containers, the SSE blockId may be a cloned ID (e.g. P1__obranch-0).
498+
// Also record the original workflow-level ID so the canvas can highlight it.
499+
if (isContainerBlockType(data.blockType)) {
500+
const originalId = stripCloneSuffixes(data.blockId)
501+
if (originalId !== data.blockId) {
502+
executedBlockIds.add(originalId)
503+
if (workflowId) setBlockRunStatus(workflowId, originalId, 'success')
504+
}
505+
}
506+
497507
if (isContainerBlockType(data.blockType) && !data.iterationContainerId) {
498508
return
499509
}
@@ -525,6 +535,15 @@ export function useWorkflowExecution() {
525535
executionTime: data.durationMs || 0,
526536
})
527537

538+
// For nested containers, also record the original workflow-level ID
539+
if (isContainerBlockType(data.blockType)) {
540+
const originalId = stripCloneSuffixes(data.blockId)
541+
if (originalId !== data.blockId) {
542+
executedBlockIds.add(originalId)
543+
if (workflowId) setBlockRunStatus(workflowId, originalId, 'error')
544+
}
545+
}
546+
528547
accumulatedBlockLogs.push(
529548
createBlockLogEntry(data, { success: false, output: {}, error: data.error })
530549
)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ const defaultEdgeOptions = { type: 'custom' }
198198
const reactFlowStyles = [
199199
'bg-[var(--bg)]',
200200
'[&_.react-flow__edges]:!z-0',
201-
'[&_.react-flow__node]:!z-[21]',
201+
'[&_.react-flow__node]:z-[21]',
202202
'[&_.react-flow__handle]:!z-[30]',
203203
'[&_.react-flow__edge-labels]:!z-[60]',
204204
'[&_.react-flow__pane]:!bg-[var(--bg)]',

apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -289,21 +289,32 @@ export function PreviewWorkflow({
289289
return map
290290
}, [executedBlocks])
291291

292-
/** Derives subflow status from children. Error takes precedence. */
292+
/** Derives subflow status from children. Recursively checks nested subflows. Error takes precedence. */
293293
const getSubflowExecutionStatus = useMemo(() => {
294-
return (subflowId: string): ExecutionStatus | undefined => {
294+
const derive = (subflowId: string): ExecutionStatus | undefined => {
295295
const childIds = subflowChildrenMap.get(subflowId)
296296
if (!childIds?.length) return undefined
297297

298-
const executedChildren = childIds
299-
.map((id) => blockExecutionMap.get(id))
300-
.filter((status): status is { status: string } => Boolean(status))
298+
const childStatuses: string[] = []
299+
for (const childId of childIds) {
300+
const direct = blockExecutionMap.get(childId)
301+
if (direct) {
302+
childStatuses.push(direct.status)
303+
} else {
304+
const childBlock = workflowState.blocks?.[childId]
305+
if (childBlock?.type === 'loop' || childBlock?.type === 'parallel') {
306+
const nested = derive(childId)
307+
if (nested) childStatuses.push(nested)
308+
}
309+
}
310+
}
301311

302-
if (executedChildren.length === 0) return undefined
303-
if (executedChildren.some((s) => s.status === 'error')) return 'error'
312+
if (childStatuses.length === 0) return undefined
313+
if (childStatuses.some((s) => s === 'error')) return 'error'
304314
return 'success'
305315
}
306-
}, [subflowChildrenMap, blockExecutionMap])
316+
return derive
317+
}, [subflowChildrenMap, blockExecutionMap, workflowState.blocks])
307318

308319
/** Gets block status. Subflows derive status from children. */
309320
const getBlockExecutionStatus = useMemo(() => {

apps/sim/lib/logs/execution/trace-spans/trace-spans.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import type { ToolCall, TraceSpan } from '@/lib/logs/types'
33
import { isWorkflowBlockType, stripCustomToolPrefix } from '@/executor/constants'
44
import type { ExecutionResult } from '@/executor/types'
5+
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
56

67
const logger = createLogger('TraceSpans')
78

@@ -569,28 +570,31 @@ interface ContainerNameCounters {
569570

570571
/**
571572
* Resolves a container name from normal (non-iteration) spans or assigns a sequential number.
573+
* Strips clone suffixes so all clones of the same container share one name/number.
572574
*/
573575
function resolveContainerName(
574576
containerId: string,
575577
containerType: 'parallel' | 'loop',
576578
normalSpans: TraceSpan[],
577579
counters: ContainerNameCounters
578580
): string {
581+
const originalId = stripCloneSuffixes(containerId)
582+
579583
const matchingBlock = normalSpans.find(
580-
(s) => s.blockId === containerId && s.type === containerType
584+
(s) => s.blockId === originalId && s.type === containerType
581585
)
582586
if (matchingBlock?.name) return matchingBlock.name
583587

584588
if (containerType === 'parallel') {
585-
if (!counters.parallelNumbers.has(containerId)) {
586-
counters.parallelNumbers.set(containerId, counters.parallelCounter++)
589+
if (!counters.parallelNumbers.has(originalId)) {
590+
counters.parallelNumbers.set(originalId, counters.parallelCounter++)
587591
}
588-
return `Parallel ${counters.parallelNumbers.get(containerId)}`
592+
return `Parallel ${counters.parallelNumbers.get(originalId)}`
589593
}
590-
if (!counters.loopNumbers.has(containerId)) {
591-
counters.loopNumbers.set(containerId, counters.loopCounter++)
594+
if (!counters.loopNumbers.has(originalId)) {
595+
counters.loopNumbers.set(originalId, counters.loopCounter++)
592596
}
593-
return `Loop ${counters.loopNumbers.get(containerId)}`
597+
return `Loop ${counters.loopNumbers.get(originalId)}`
594598
}
595599

596600
/**

0 commit comments

Comments
 (0)