Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions apps/docs/components/workflow-preview/docs-container-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface DocsContainerData {
name: string
blockType: string
size?: { width: number; height: number }
parentId?: string
}

/**
Expand All @@ -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,
}

Expand Down
63 changes: 63 additions & 0 deletions apps/docs/components/workflow-preview/workflow-data.test.ts
Original file line number Diff line number Diff line change
@@ -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))
})
})
37 changes: 36 additions & 1 deletion apps/docs/components/workflow-preview/workflow-data.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -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.
*
Expand All @@ -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.
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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'
Expand All @@ -142,6 +176,7 @@ export function toReactFlowElements(
},
sourceHandle,
targetHandle,
zIndex: getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex),
data: {
animate,
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
EDGE_Z_MAX,
getBlockZIndex,
getEdgeZIndex,
getEdgeZIndexForTarget,
getNoteBlockHeight,
normalizeCursorSourceHandleId,
} from '@sim/workflow-renderer'
Expand Down Expand Up @@ -4886,10 +4887,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,
Expand All @@ -4898,6 +4905,7 @@ const WorkflowContent = React.memo(
parentLoopId,
sourceHandle: edge.sourceHandle,
onDelete: handleEdgeDelete,
...(targetContainerZIndex !== undefined ? { labelZIndex: zIndex } : {}),
},
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions packages/workflow-renderer/src/canvas-layers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
EDGE_Z_MAX,
getBlockZIndex,
getEdgeZIndex,
getEdgeZIndexForTarget,
} from './canvas-layers'

/**
Expand Down Expand Up @@ -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)
})
})
34 changes: 28 additions & 6 deletions packages/workflow-renderer/src/canvas-layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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>()

Expand Down Expand Up @@ -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 })
})
})
Loading
Loading