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
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ import {
useIsBlockInActiveExecutionHandoff,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import {
isEdgeConnectedToEditor,
isEdgeHighlighted,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/edge-highlight'
import { hasBlockAccent } from '@/blocks/accent'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getBlock } from '@/blocks/registry'
Expand Down Expand Up @@ -716,19 +720,21 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const keys: string[] = []
for (const edge of state.edges) {
if (edge.source !== id && edge.target !== id) continue
/*
* Must mirror workflow-edge's shouldHighlightEdge exactly: the edge
* darkens when an endpoint is canvas-selected OR open in the editor
* panel. If the knob checks fewer conditions than the line, a dark
* line runs into a light knob.
*/
const isHighlighted =
state.nodeInternals.get(edge.source)?.selected ||
state.nodeInternals.get(edge.target)?.selected ||
(edge.data as { isConnectedToSelection?: boolean } | undefined)
?.isConnectedToSelection ||
(editorOpenBlockId !== null &&
(edge.source === editorOpenBlockId || edge.target === editorOpenBlockId))
/* Same predicate the line itself uses — a knob checking fewer
conditions than the edge leaves a dark line running into a light
knob. */
const isHighlighted = isEdgeHighlighted({
isEndpointSelected:
state.nodeInternals.get(edge.source)?.selected ||
state.nodeInternals.get(edge.target)?.selected ||
(edge.data as { isConnectedToSelection?: boolean } | undefined)
?.isConnectedToSelection,
isConnectedToEditor: isEdgeConnectedToEditor(
editorOpenBlockId,
edge.source,
edge.target
),
})
if (!isHighlighted) continue
if (edge.source === id) keys.push(edge.sourceHandle || 'source')
if (edge.target === id) keys.push(edge.targetHandle || 'target')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { memo, useCallback, useMemo } from 'react'
import { type EdgeDiffStatus, WorkflowEdgeView } from '@sim/workflow-renderer'
import { type EdgeProps, useStore } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import {
isEdgeConnectedToEditor,
isEdgeHighlighted,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/edge-highlight'
import {
useIsBlockActive,
useIsCurrentWorkflowExecuting,
Expand Down Expand Up @@ -55,11 +59,15 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => {
isEndpointSelected ||
(data as { isConnectedToSelection?: boolean } | undefined)?.isConnectedToSelection
)
const isConnectedToEditor =
activeTab === 'editor' &&
currentBlockId !== null &&
(currentBlockId === source || currentBlockId === target)
const shouldHighlightEdge = isConnectedToSelection || isConnectedToEditor
const isConnectedToEditor = isEdgeConnectedToEditor(
activeTab === 'editor' ? currentBlockId : null,
source,
target
)
const shouldHighlightEdge = isEdgeHighlighted({
isEndpointSelected: isConnectedToSelection,
isConnectedToEditor,
})

const previewExecutionStatus = (
data as { executionStatus?: 'success' | 'error' | 'not-executed' } | undefined
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Whether an edge is drawn highlighted.
*
* Three places need this answer and each reaches it from a different source —
* the edge itself from the React Flow store, a card from the same store while
* deciding which of its knobs to darken, and the canvas while assigning the
* edge's z so a highlighted line is not crossed by an ordinary one. They have
* to agree: a knob checking fewer conditions than the line leaves a dark line
* running into a light knob, and a z checking fewer leaves the highlight cut in
* half by whatever crosses it.
*
* They agreed by being copied, which is the arrangement that produced both of
* those bugs. This is the one definition.
*/
export function isEdgeHighlighted(state: {
/** Either endpoint is selected on the canvas. */
isEndpointSelected?: boolean
/** Either endpoint is the block open in the editor panel. */
isConnectedToEditor?: boolean
/** The edge itself is selected. */
isEdgeSelected?: boolean
}): boolean {
return Boolean(state.isEndpointSelected || state.isConnectedToEditor || state.isEdgeSelected)
}

/**
* Whether an edge touches the block currently open in the editor panel.
*
* `null` while the panel is on another tab, so a block left open behind the
* console does not keep its edges lit.
*/
export function isEdgeConnectedToEditor(
editorOpenBlockId: string | null,
source: string,
target: string
): boolean {
return (
editorOpenBlockId !== null && (source === editorOpenBlockId || target === editorOpenBlockId)
)
}
51 changes: 45 additions & 6 deletions apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ import {
shouldHighlightContainerDropTarget,
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import {
isEdgeConnectedToEditor,
isEdgeHighlighted,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/edge-highlight'
import {
defaultEdgeOptions,
edgeTypes,
Expand Down Expand Up @@ -127,7 +131,7 @@ import {
} from '@/stores/execution'
import { useSearchModalStore } from '@/stores/modals/search/store'
import type { PendingConnect } from '@/stores/modals/search/types'
import { usePanelEditorStore } from '@/stores/panel'
import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useVariablesModalStore } from '@/stores/variables/modal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
Expand Down Expand Up @@ -4461,6 +4465,11 @@ const WorkflowContent = React.memo(
}, [closeConnectionBlockSelector, displayNodes, lastInteractedNodeId, pendingConnect])

/** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */
/* Subscribed rather than read from `getState()`: the edge z below depends on
which block is open, so the memo has to re-run when that changes. */
const editorOpenBlockId = usePanelEditorStore((state) => state.currentBlockId)
const panelActiveTab = usePanelStore((state) => state.activeTab)

const edgesWithSelection = useMemo(() => {
const nodeMap = new Map(displayNodes.map((n) => [n.id, n]))
/* Indexed once: this memo re-runs on every drag frame, and scanning the
Expand All @@ -4478,21 +4487,38 @@ const WorkflowContent = React.memo(
// pointer events, so the edge has to be above it to stay clickable) and
// still below that container's own children.
//
// A highlighted edge takes the top of that band instead, so no ordinary
// edge can cross over the one the user has picked out. Depth only ever
// ordered lines against each other, and an unselected edge one level
// deeper was painting straight through the highlight.
//
// Edges are NEVER elevated above cards — not even when an endpoint is
// selected. A line always passes behind cards, knobs, and the action
// bar swell; elevating highlighted edges drew them across their own
// endpoint's chrome.
// endpoint's chrome. The highlighted tier stays inside the band for
// exactly that reason.
const containerNode = parentLoopId ? nodeMap.get(parentLoopId) : null
const baseZIndex = getEdgeZIndex(containerNode ? (containerNode.zIndex ?? 0) : undefined)
const isConnectedToSelection =
selectedNodeIdSet.has(edge.source) || selectedNodeIdSet.has(edge.target)
const isSelected = selectedEdges.has(edgeContextId)
const baseZIndex = getEdgeZIndex(containerNode ? (containerNode.zIndex ?? 0) : undefined, {
isHighlighted: isEdgeHighlighted({
isEndpointSelected: isConnectedToSelection,
isConnectedToEditor: isEdgeConnectedToEditor(
panelActiveTab === 'editor' ? editorOpenBlockId : null,
edge.source,
edge.target
),
isEdgeSelected: isSelected,
}),
})
Comment thread
waleedlatif1 marked this conversation as resolved.

return {
...edge,
zIndex: baseZIndex,
data: {
...edge.data,
isSelected: selectedEdges.has(edgeContextId),
isSelected,
isConnectedToSelection,
isInsideLoop: Boolean(parentLoopId),
parentLoopId,
Expand All @@ -4501,7 +4527,15 @@ const WorkflowContent = React.memo(
},
}
})
}, [edgesForDisplay, displayNodes, selectedNodeIds, selectedEdges, handleEdgeDelete])
}, [
edgesForDisplay,
displayNodes,
selectedNodeIds,
selectedEdges,
handleEdgeDelete,
editorOpenBlockId,
panelActiveTab,
])

const edgesForRender = useMemo(() => {
if (!pendingConnect) return edgesWithSelection
Expand All @@ -4520,7 +4554,12 @@ const WorkflowContent = React.memo(
target: CONNECTION_BLOCK_SELECTOR_NODE_ID,
targetHandle: 'target',
type: 'workflowEdge',
zIndex: getEdgeZIndex(sourceParentNode ? (sourceParentNode.zIndex ?? 0) : undefined),
/* Rendered highlighted (`isConnectedToSelection` below), so it is
elevated like any other highlighted edge — the preview line is the
one the user is currently drawing. */
zIndex: getEdgeZIndex(sourceParentNode ? (sourceParentNode.zIndex ?? 0) : undefined, {
isHighlighted: true,
}),
focusable: false,
deletable: false,
reconnectable: false,
Expand Down
71 changes: 71 additions & 0 deletions packages/workflow-renderer/src/canvas-layers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
BLOCK_Z_BASE,
CONTAINER_CHILD_Z_BASE,
EDGE_Z_BASE,
EDGE_Z_MAX,
getBlockZIndex,
getEdgeZIndex,
} from './canvas-layers'

/**
* Nesting depths an edge is tiered by. Stops short of the band's ceiling: past
* it every edge saturates at the same tier, which is checked on its own below.
*/
const DEPTHS = [undefined, 0, 1, 2, 5]

describe('getEdgeZIndex', () => {
it('puts a highlighted edge over every ordinary one, however deeply nested', () => {
/* The reported bug: a highlighted edge kept its own container's depth, so an
ordinary edge one level deeper painted over it and cut the highlight. */
const highlighted = getEdgeZIndex(undefined, { isHighlighted: true })

for (const depth of DEPTHS) {
expect(getEdgeZIndex(depth)).toBeLessThan(highlighted)
}
})

it('keeps a highlighted edge below the cards', () => {
/* Elevating highlighted edges over the cards drew them across the chrome of
their own endpoints, so the highlighted tier stays inside the edge band. */
const highlighted = getEdgeZIndex(undefined, { isHighlighted: true })

expect(highlighted).toBeLessThan(BLOCK_Z_BASE)
expect(highlighted).toBeLessThan(getBlockZIndex(BLOCK_Z_BASE))
expect(highlighted).toBeLessThan(CONTAINER_CHILD_Z_BASE)
})

it('leaves the in-flight connection line above everything in the band', () => {
expect(getEdgeZIndex(undefined, { isHighlighted: true })).toBeLessThan(EDGE_Z_MAX)
for (const depth of DEPTHS) {
expect(getEdgeZIndex(depth)).toBeLessThan(EDGE_Z_MAX)
}
})

it('still orders ordinary edges by the depth they are nested at', () => {
expect(getEdgeZIndex(undefined)).toBe(EDGE_Z_BASE)
expect(getEdgeZIndex(0)).toBeGreaterThan(getEdgeZIndex(undefined))
expect(getEdgeZIndex(1)).toBeGreaterThan(getEdgeZIndex(0))
})

it('keeps every edge clear of the container bodies it crosses', () => {
/* Containers are numbered from 0 by nesting depth; an edge sharing a body's
z loses the equal-z tiebreak to DOM order and is drawn behind it. */
for (const depth of DEPTHS) {
expect(getEdgeZIndex(depth)).toBeGreaterThan(depth ?? 0)
expect(getEdgeZIndex(depth, { isHighlighted: true })).toBeGreaterThan(depth ?? 0)
}
})

it('saturates rather than growing past the band', () => {
/* The band is fixed, so beyond its ceiling every edge shares the deepest
tier and no longer clears a container nested that far — true before this
change too, at a ceiling of `EDGE_Z_MAX` rather than one below the
highlighted tier. Nothing in the editor nests anywhere near it. */
expect(getEdgeZIndex(40)).toBe(getEdgeZIndex(8))
expect(getEdgeZIndex(8)).toBeLessThan(getEdgeZIndex(undefined, { isHighlighted: true }))
})
})
30 changes: 28 additions & 2 deletions packages/workflow-renderer/src/canvas-layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@
* once already and took the preview's edges behind its containers with it.
*/
export const EDGE_Z_BASE = 10
/**
* Deepest nesting tier an ordinary edge reaches, leaving the top of the band to
* the two edges that have to be seen whole.
*/
const EDGE_Z_DEPTH_MAX = 18
/**
* A highlighted edge — selected, or connected to the selected card. Above every
* ordinary edge whatever it is nested in, because the highlight is what the
* user is looking at and a line crossing it from a deeper container was cutting
* it in half.
*
* Still inside the edge band, deliberately. Highlighted edges used to be
* elevated over the cards as well, which drew them across the chrome of their
* own endpoints; a line belongs behind cards, knobs and the action-bar swell
* whether or not it is highlighted.
*/
const EDGE_Z_HIGHLIGHTED = 19
export const EDGE_Z_MAX = 20
export const BLOCK_Z_BASE = 21
export const CONTAINER_CHILD_Z_BASE = 1000
Expand All @@ -41,10 +58,19 @@ export function getBlockZIndex(
* it belongs to, so an edge always clears the container body it crosses while
* staying under that container's own children.
*
* A highlighted edge leaves that ordering and takes {@link EDGE_Z_HIGHLIGHTED}
* instead. Depth is only a tiebreak between lines nobody is looking at; once one
* is highlighted, being drawn whole matters more than which container it came
* from — an unselected edge one level deeper used to paint straight over it.
*
* `containerZIndex` is the parent container's own z (its nesting depth), or
* undefined for an edge at the top level.
*/
export function getEdgeZIndex(containerZIndex: number | undefined): number {
export function getEdgeZIndex(
containerZIndex: number | undefined,
state: { isHighlighted?: boolean } = {}
): number {
if (state.isHighlighted) return EDGE_Z_HIGHLIGHTED
const depth = containerZIndex === undefined ? 0 : containerZIndex + 1
return Math.min(EDGE_Z_BASE + depth, EDGE_Z_MAX)
return Math.min(EDGE_Z_BASE + depth, EDGE_Z_DEPTH_MAX)
}
Loading