fix(workflow): hide idle nested subflow end handles (#6976)

* fix(workflow): hide idle nested subflow end handles

* perf(workflow): avoid repeated subflow edge scans

* perf(workflow): stabilize subflow edge selector

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Waleed Latif <walif6@gmail.com>
This commit is contained in:
Bill Leoutsakos
2026-08-24 00:58:03 -07:00
committed by GitHub
parent 67fc2aeb30
commit 528b34f564
15 changed files with 383 additions and 20 deletions
@@ -8,6 +8,7 @@ interface DocsContainerData {
name: string
blockType: string
size?: { width: number; height: number }
parentId?: string
}
/**
@@ -24,6 +25,7 @@ export const DocsContainerNode = memo(function DocsContainerNode({
name: data.name,
width: data.size?.width,
height: data.size?.height,
parentId: data.parentId,
isPreview: true,
}
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/
import { BLOCK_Z_BASE, CONTAINER_CHILD_Z_BASE, getEdgeZIndex } from '@sim/workflow-renderer'
import { describe, expect, it } from 'vitest'
import { type PreviewBlock, type PreviewWorkflow, toReactFlowElements } from './workflow-data'
const block = (
overrides: Partial<PreviewBlock> & Pick<PreviewBlock, 'id' | 'type'>
): PreviewBlock => ({
name: overrides.id,
bgColor: '#000000',
rows: [],
position: { x: 0, y: 0 },
...overrides,
})
const workflow: PreviewWorkflow = {
id: 'nested-subflows',
name: 'Nested subflows',
blocks: [
block({ id: 'start', type: 'starter' }),
block({ id: 'loop', type: 'loop', size: { width: 500, height: 300 } }),
block({
id: 'parallel',
type: 'parallel',
parentId: 'loop',
position: { x: 24, y: 64 },
size: { width: 400, height: 200 },
}),
block({ id: 'agent', type: 'agent', parentId: 'loop', position: { x: 24, y: 140 } }),
],
edges: [
{ id: 'start-loop', source: 'start', target: 'loop' },
{ id: 'loop-parallel', source: 'loop', target: 'parallel' },
{ id: 'loop-agent', source: 'loop', target: 'agent' },
],
}
describe('toReactFlowElements layering', () => {
it('places incoming edges on their container target layer', () => {
const { nodes, edges } = toReactFlowElements(workflow, false, {
highlightEdge: 'loop-parallel',
})
const nodeById = new Map(nodes.map((node) => [node.id, node]))
const edgeById = new Map(edges.map((edge) => [edge.id, edge]))
expect(nodeById.get('loop')?.zIndex).toBe(0)
expect(nodeById.get('parallel')?.zIndex).toBe(1)
expect(edgeById.get('start-loop')?.zIndex).toBe(0)
expect(edgeById.get('loop-parallel')?.zIndex).toBe(1)
})
it('keeps ordinary cards above normally layered edges', () => {
const { nodes, edges } = toReactFlowElements(workflow)
const nodeById = new Map(nodes.map((node) => [node.id, node]))
const edgeById = new Map(edges.map((edge) => [edge.id, edge]))
expect(nodeById.get('start')?.zIndex).toBe(BLOCK_Z_BASE)
expect(nodeById.get('agent')?.zIndex).toBe(CONTAINER_CHILD_Z_BASE)
expect(edgeById.get('loop-agent')?.zIndex).toBe(getEdgeZIndex(0))
})
})
@@ -1,3 +1,9 @@
import {
BLOCK_Z_BASE,
CONTAINER_CHILD_Z_BASE,
getEdgeZIndex,
getEdgeZIndexForTarget,
} from '@sim/workflow-renderer'
import { type Edge, type Node, Position } from 'reactflow'
/**
@@ -61,6 +67,24 @@ export interface HighlightOptions {
selectedBlock?: string
}
/** Semantic container depth used for z-order while docs positions stay flattened. */
function getNestingDepth(block: PreviewBlock, blocksById: Map<string, PreviewBlock>): number {
let depth = 0
let parentId = block.parentId
const visited = new Set<string>()
while (parentId && !visited.has(parentId)) {
const parent = blocksById.get(parentId)
if (!parent) break
visited.add(parentId)
depth += 1
parentId = parent.parentId
}
return depth
}
/**
* Converts a {@link PreviewWorkflow} to React Flow nodes and edges.
*
@@ -81,6 +105,7 @@ export function toReactFlowElements(
const nodes: Node[] = workflow.blocks.map((block, index) => {
const isContainer = Boolean(block.size)
const nestingDepth = getNestingDepth(block, blocksById)
// Nested blocks are authored relative to their container; render them at
// absolute coordinates (not React Flow parentNode children) so the edges
// between a container and its nested blocks render reliably and on top.
@@ -92,7 +117,7 @@ export function toReactFlowElements(
id: block.id,
type: isContainer ? 'previewContainer' : 'previewBlock',
position,
zIndex: isContainer ? 0 : 1,
zIndex: isContainer ? nestingDepth : block.parentId ? CONTAINER_CHILD_Z_BASE : BLOCK_Z_BASE,
...(block.size ? { style: { width: block.size.width, height: block.size.height } } : {}),
data: {
name: block.name,
@@ -103,6 +128,7 @@ export function toReactFlowElements(
tools: block.tools,
hideTargetHandle: block.hideTargetHandle,
size: block.size,
parentId: block.parentId,
index,
animate,
isHighlighted: highlightBlock === block.id || selectedBlock === block.id,
@@ -127,6 +153,14 @@ export function toReactFlowElements(
// so edges into and out of Loop/Parallel containers still connect.
const sourceBlock = blocksById.get(e.source)
const targetBlock = blocksById.get(e.target)
const parentContainer = blocksById.get(sourceBlock?.parentId ?? targetBlock?.parentId ?? '')
const baseZIndex = getEdgeZIndex(
parentContainer ? getNestingDepth(parentContainer, blocksById) : undefined,
{ isHighlighted: isEdgeHighlight }
)
const targetContainerZIndex = targetBlock?.size
? getNestingDepth(targetBlock, blocksById)
: undefined
const sourceHandle =
e.sourceHandle ?? (sourceBlock?.size ? `${sourceBlock.type}-end-source` : 'source')
const targetHandle = targetBlock?.size ? undefined : 'target'
@@ -142,6 +176,7 @@ export function toReactFlowElements(
},
sourceHandle,
targetHandle,
zIndex: getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex),
data: {
animate,
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
+3 -1
View File
@@ -10,6 +10,7 @@
"build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build",
"start": "next start",
"postinstall": "fumadocs-mdx",
"test": "vitest run",
"type-check": "fumadocs-mdx && tsc --noEmit",
"lint": "biome check --write --unsafe .",
"lint:check": "biome check .",
@@ -47,6 +48,7 @@
"@types/react-dom": "^19.0.4",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.12",
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
}
@@ -28,6 +28,7 @@ import {
EDGE_Z_MAX,
getBlockZIndex,
getEdgeZIndex,
getEdgeZIndexForTarget,
getNoteBlockHeight,
normalizeCursorSourceHandleId,
} from '@sim/workflow-renderer'
@@ -4887,10 +4888,16 @@ const WorkflowContent = React.memo(
isEdgeSelected: isSelected,
}),
})
const targetContainerZIndex =
targetNode?.type === 'subflowNode' ? (targetNode.zIndex ?? 0) : undefined
// The target node paints after an equal-z edge. A nested container is
// one depth above its parent, so this hides only the segment beneath
// the target while leaving the route visible over the parent body.
const zIndex = getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex)
return {
...edge,
zIndex: baseZIndex,
zIndex,
data: {
...edge.data,
isSelected,
@@ -4899,6 +4906,7 @@ const WorkflowContent = React.memo(
parentLoopId,
sourceHandle: edge.sourceHandle,
onDelete: handleEdgeDelete,
...(targetContainerZIndex !== undefined ? { labelZIndex: zIndex } : {}),
},
}
})
@@ -12,6 +12,7 @@ interface WorkflowPreviewSubflowData {
width?: number
height?: number
kind: 'loop' | 'parallel'
parentId?: string
/** Whether this subflow is enabled */
enabled?: boolean
/** Whether this subflow is selected in preview mode */
@@ -22,6 +22,7 @@ import {
CONTAINER_DIMENSIONS,
EDGE_Z_BASE,
EDGE_Z_MAX,
getEdgeZIndexForTarget,
} from '@sim/workflow-renderer'
import { normalizeWorkflowEdgeHandles } from '@sim/workflow-types/workflow'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
@@ -567,6 +568,14 @@ export function PreviewWorkflow({
return normalizeWorkflowEdgeHandles(workflowState.edges).map((edge) => {
const status = getEdgeExecutionStatus(edge)
const isErrorEdge = edge.sourceHandle === 'error'
const baseZIndex =
status === 'success' ? EDGE_Z_MAX : isErrorEdge ? EDGE_Z_BASE + 2 : EDGE_Z_BASE
const targetBlock = workflowState.blocks[edge.target]
const targetContainerZIndex =
targetBlock?.type === 'loop' || targetBlock?.type === 'parallel'
? calculateNestingDepth(targetBlock, workflowState.blocks)
: undefined
return {
id: edge.id,
source: edge.source,
@@ -580,12 +589,14 @@ export function PreviewWorkflow({
/* Inside the shared edge band, so a line clears the opaque container it
crosses and still passes behind cards. Execution status orders edges
within the band: a successful path draws over an error one, which
draws over an unexecuted one. */
zIndex: status === 'success' ? EDGE_Z_MAX : isErrorEdge ? EDGE_Z_BASE + 2 : EDGE_Z_BASE,
draws over an unexecuted one. A Loop/Parallel target overrides that
ordering so its node paints over the incoming segment. */
zIndex: getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex),
}
})
}, [
edgesStructure,
workflowState.blocks,
workflowState.edges,
isValidWorkflowState,
blockExecutionMap,
+1
View File
@@ -93,6 +93,7 @@
"postcss": "^8.5.3",
"tailwindcss": "^4.0.12",
"typescript": "^7.0.2",
"vitest": "^4.1.0",
},
},
"apps/pii": {
@@ -9,6 +9,7 @@ import {
EDGE_Z_MAX,
getBlockZIndex,
getEdgeZIndex,
getEdgeZIndexForTarget,
} from './canvas-layers'
/**
@@ -69,3 +70,36 @@ describe('getEdgeZIndex', () => {
expect(getEdgeZIndex(8)).toBeLessThan(getEdgeZIndex(undefined, { isHighlighted: true }))
})
})
describe('getEdgeZIndexForTarget', () => {
it('shares a container target layer so the node paints over the incoming edge', () => {
const parentZIndex = 0
const targetZIndex = 1
const edgeZIndex = getEdgeZIndex(parentZIndex)
const resolved = getEdgeZIndexForTarget(edgeZIndex, targetZIndex)
expect(resolved).toBe(targetZIndex)
expect(resolved).toBeGreaterThan(parentZIndex)
})
it('places incoming edges beneath top-level container targets', () => {
expect(getEdgeZIndexForTarget(EDGE_Z_BASE, 0)).toBe(0)
})
it('does not let highlighting elevate an edge over its container target', () => {
const highlighted = getEdgeZIndex(undefined, { isHighlighted: true })
expect(getEdgeZIndexForTarget(highlighted, 2)).toBe(2)
})
it('does not let an execution edge elevate over its container target', () => {
expect(getEdgeZIndexForTarget(EDGE_Z_MAX, 2)).toBe(2)
})
it('leaves edges to ordinary blocks unchanged', () => {
const edgeZIndex = getEdgeZIndex(1)
expect(getEdgeZIndexForTarget(edgeZIndex, undefined)).toBe(edgeZIndex)
})
})
@@ -9,12 +9,15 @@
* - {@link CONTAINER_CHILD_Z_BASE} cards inside a container (same +1 / +10 steps)
* - {@link CONNECTION_PICKER_Z} the connection block picker
*
* Containers and edges must occupy separate bands. A container paints an opaque
* body, so an edge sharing its z loses the equal-z tiebreak to DOM order React
* Flow renders the nodes layer after the edges layer and is drawn *behind* the
* container. That is what hid every line crossing a top-level subflow, whether
* in flight or persisted. Cards then sit above the edge band, so a line still
* passes behind card chrome, knobs, and the action-bar swell.
* Containers and ordinary edges occupy separate bands. A container paints an
* opaque body, so an edge sharing its z loses the equal-z tiebreak to DOM order
* React Flow renders the nodes layer after the edges layer and is drawn
* *behind* the container. Incoming container edges deliberately use that rule
* at their target's depth: the target sits above its parent by one depth, leaving
* the edge over the parent body but beneath the target. The edge can also be
* occluded by peer or higher-depth containers it crosses. Cards then sit above
* the edge band, so every other line still passes behind card chrome, knobs, and
* the action-bar swell.
*
* Shared by the editor canvas and the read-only preview because both render the
* same graph through the same React Flow layering rules; a second scale drifted
@@ -74,3 +77,22 @@ export function getEdgeZIndex(
const depth = containerZIndex === undefined ? 0 : containerZIndex + 1
return Math.min(EDGE_Z_BASE + depth, EDGE_Z_DEPTH_MAX)
}
/**
* Keeps an incoming edge beneath a Loop/Parallel target without hiding it
* behind that target's parent.
*
* Containers use their nesting depth as z-index, so a nested target is exactly
* one layer above its parent. React Flow renders equal-z edges before nodes;
* sharing the target's layer therefore leaves the edge visible over the parent
* body while the target paints over the segment that reaches beneath it.
*
* `targetContainerZIndex` must only be supplied when the edge targets a
* container. Ordinary edges retain their existing depth/highlight ordering.
*/
export function getEdgeZIndexForTarget(
edgeZIndex: number,
targetContainerZIndex: number | undefined
): number {
return targetContainerZIndex ?? edgeZIndex
}
@@ -1,12 +1,20 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { Position } from 'reactflow'
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { WorkflowEdgeView, type WorkflowEdgeViewProps } from '../index'
vi.mock('reactflow', async (importOriginal) => {
const actual = await importOriginal<typeof import('reactflow')>()
return {
...actual,
EdgeLabelRenderer: ({ children }: { children: ReactNode }) => <>{children}</>,
}
})
const mountedHosts = new Set<HTMLDivElement>()
const mountedRoots = new Set<Root>()
@@ -210,4 +218,12 @@ describe('WorkflowEdgeView', () => {
expect(path?.style.stroke).toBe('var(--text-error)')
})
it('keeps the selected-edge control on a container target occlusion layer', () => {
const { host } = renderEdge({
data: { isSelected: true, labelZIndex: 1 },
})
expect(host.querySelector('button')).toHaveStyle({ zIndex: 1 })
})
})
@@ -7,6 +7,7 @@ import type { EdgeDiffStatus, EdgeRunStatus } from '../types'
const EXECUTION_PULSE_LENGTH = 0.32
const EXECUTION_PULSE_CYCLE_LENGTH = 2.2
const EXECUTION_PULSE_DURATION = '1100ms'
const DEFAULT_EDGE_LABEL_Z_INDEX = 1011
/**
* How far the glow reaches past the path, in user space.
@@ -125,6 +126,8 @@ export function WorkflowEdgeView({
}, [isHorizontal, sourceX, sourceY, targetX, targetY])
const isSelected = data?.isSelected ?? false
const labelZIndex =
(data as { labelZIndex?: number } | undefined)?.labelZIndex ?? DEFAULT_EDGE_LABEL_Z_INDEX
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
@@ -268,7 +271,7 @@ export function WorkflowEdgeView({
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
zIndex: 1011,
zIndex: labelZIndex,
}}
onClick={(e) => {
e.preventDefault()
+1
View File
@@ -6,6 +6,7 @@ export {
EDGE_Z_MAX,
getBlockZIndex,
getEdgeZIndex,
getEdgeZIndexForTarget,
} from './canvas-layers'
export * from './dimensions'
export { WorkflowEdgeView, type WorkflowEdgeViewProps } from './edge/workflow-edge-view'
@@ -5,6 +5,8 @@ import {
Handle,
internalsSymbol,
Position,
type ReactFlowState,
useStore as useReactFlowStore,
useStoreApi as useReactFlowStoreApi,
useUpdateNodeInternals,
} from 'reactflow'
@@ -343,6 +345,22 @@ export function SubflowNodeView({
const isPreviewSelected = data?.isPreviewSelected || false
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
const showFixedEndPort = useReactFlowStore(
useMemo(() => {
let previousEdges: ReactFlowState['edges'] | undefined
let previousResult = !data.parentId
return (state: ReactFlowState) => {
if (!data.parentId || state.edges === previousEdges) return previousResult
previousEdges = state.edges
previousResult = state.edges.some(
(edge) => edge.source === id && edge.sourceHandle === endHandleId
)
return previousResult
}
}, [data.parentId, endHandleId, id])
)
const BlockIcon = data.kind === 'loop' ? Repeat : Split
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
const blockTypeLabel = data.kind === 'loop' ? 'Loop' : 'Parallel'
@@ -474,13 +492,16 @@ export function SubflowNodeView({
position: HANDLE_POSITIONS.SUBFLOW_CONNECTION_Y,
plateau: CURSOR_SWELL_LENGTH_PX,
},
{
]
if (showFixedEndPort) {
ports.push({
id: endHandleId,
side: 'right',
position: HANDLE_POSITIONS.SUBFLOW_CONNECTION_Y,
plateau: CURSOR_SWELL_LENGTH_PX,
},
]
})
}
if (showActionMenu) {
ports.push({
@@ -495,7 +516,7 @@ export function SubflowNodeView({
}
return ports
}, [actionMenuSwellOpen, actionMenuWidth, endHandleId, showActionMenu])
}, [actionMenuSwellOpen, actionMenuWidth, endHandleId, showActionMenu, showFixedEndPort])
return (
<div
@@ -5,13 +5,13 @@
* a knob-paint bug once threw only when a card had a coloured knob invisible
* on an idle canvas, fatal on node creation.
*/
import { act } from 'react'
import { act, Profiler, useLayoutEffect } from 'react'
import {
normalizeWorkflowEdgeSourceHandle,
normalizeWorkflowEdgeTargetHandle,
} from '@sim/workflow-types/workflow'
import { createRoot, type Root } from 'react-dom/client'
import { ReactFlowProvider } from 'reactflow'
import { type Edge, ReactFlowProvider, useStoreApi as useReactFlowStoreApi } from 'reactflow'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { CONTAINER_DIMENSIONS } from '../dimensions'
import {
@@ -83,6 +83,55 @@ function mount(element: React.ReactElement) {
return { host, root }
}
function SubflowMountFixture({
id,
kind,
parentId,
edges,
onRender,
}: {
id: string
kind: 'loop' | 'parallel'
parentId?: string
edges?: Edge[]
onRender?: () => void
}) {
const reactFlowStore = useReactFlowStoreApi()
useLayoutEffect(() => {
if (edges) reactFlowStore.setState({ edges })
}, [edges, reactFlowStore])
const view = (
<SubflowNodeView
id={id}
data={{ kind, name: kind === 'loop' ? 'Loop' : 'Parallel', parentId, isPreview: true }}
isEnabled
isLocked={false}
isFocused={false}
nestingLevel={parentId ? 1 : 0}
canEditWorkflow={false}
onSelect={() => undefined}
/>
)
return onRender ? (
<Profiler id={id} onRender={onRender}>
{view}
</Profiler>
) : (
view
)
}
function getSubflowSilhouette(host: HTMLElement, caseName: string) {
const path = host.querySelector<SVGPathElement>(
`[data-subflow-case="${caseName}"] [data-type="subflowNode"] > svg > path[fill="var(--border-1)"]`
)
expect(path).toBeTruthy()
return path?.getAttribute('d')
}
afterEach(() => {
act(() => {
mountedRoots.forEach((root) => root.unmount())
@@ -980,6 +1029,100 @@ describe('WorkflowBlockBorder mount', () => {
)
})
it.each(['loop', 'parallel'] as const)(
'only paints the fixed %s end port when its topology needs it',
(kind) => {
const endHandleId = `${kind}-end-source`
const nestedConnectedEdges: Edge[] = [
{
id: `${kind}-end-edge`,
source: `${kind}-nested-connected`,
sourceHandle: endHandleId,
target: `${kind}-sibling`,
targetHandle: 'target',
},
]
const { host } = mount(
<div>
<ReactFlowProvider>
<div data-subflow-case='top-level'>
<SubflowMountFixture id={`${kind}-top-level`} kind={kind} />
</div>
</ReactFlowProvider>
<ReactFlowProvider>
<div data-subflow-case='nested-idle'>
<SubflowMountFixture
id={`${kind}-nested-idle`}
kind={kind}
parentId={`${kind}-parent`}
/>
</div>
</ReactFlowProvider>
<ReactFlowProvider>
<div data-subflow-case='nested-connected'>
<SubflowMountFixture
id={`${kind}-nested-connected`}
kind={kind}
parentId={`${kind}-parent`}
edges={nestedConnectedEdges}
/>
</div>
</ReactFlowProvider>
</div>
)
const topLevelPath = getSubflowSilhouette(host, 'top-level')
const nestedIdlePath = getSubflowSilhouette(host, 'nested-idle')
const nestedConnectedPath = getSubflowSilhouette(host, 'nested-connected')
expect(nestedIdlePath).not.toBe(topLevelPath)
expect(nestedConnectedPath).toBe(topLevelPath)
for (const caseName of ['top-level', 'nested-idle', 'nested-connected']) {
const subflow = host.querySelector(`[data-subflow-case="${caseName}"]`)
expect(subflow?.querySelector('[data-handleid="target"]')).toBeTruthy()
expect(subflow?.querySelector(`[data-handleid="${endHandleId}"]`)).toBeTruthy()
}
}
)
it('does not rerender a nested subflow when unrelated edges change', () => {
const baselineRender = vi.fn()
const unrelatedEdgeRender = vi.fn()
const unrelatedEdges: Edge[] = [
{
id: 'unrelated-edge',
source: 'other-source',
sourceHandle: 'source',
target: 'other-target',
targetHandle: 'target',
},
]
mount(
<div>
<ReactFlowProvider>
<SubflowMountFixture
id='baseline-nested-loop'
kind='loop'
parentId='parent-loop'
onRender={baselineRender}
/>
</ReactFlowProvider>
<ReactFlowProvider>
<SubflowMountFixture
id='unrelated-edge-nested-loop'
kind='loop'
parentId='parent-loop'
edges={unrelatedEdges}
onRender={unrelatedEdgeRender}
/>
</ReactFlowProvider>
</div>
)
expect(unrelatedEdgeRender).toHaveBeenCalledTimes(baselineRender.mock.calls.length)
})
it('retracts a selected loop action swell after hover ends', () => {
vi.useFakeTimers()
vi.stubGlobal(