fix(workflow): draw a highlighted edge over the ordinary ones (#6642)

* fix(workflow): draw a highlighted edge over the ordinary ones

An edge's z came from the nesting depth of the container it belongs to, and a
highlighted edge kept that depth like any other. A line one level deeper
therefore sat above it and painted straight through the highlight, cutting it
in half wherever the two crossed.

Give a highlighted edge — selected, or connected to the selected card — the top
tier of the edge band instead. Depth only ever ordered edges against each
other, and once the user has picked one out, being drawn whole matters more
than which container it came from.

The tier stays inside the band, below the cards, deliberately: highlighted
edges were elevated over the cards once before and drew across the chrome of
their own endpoints. A line belongs behind cards, knobs and the action-bar
swell whether or not it is highlighted, so ordinary edges give up the top of
the band rather than the band being widened into the cards.

* fix(workflow): elevate the connection preview edge with the rest

It renders highlighted — its data carries `isConnectedToSelection` — but it was
the one call site left taking a depth tier, so the line being drawn could be
crossed by an ordinary edge in a deeper container. Highlighted now means
elevated with no exception.

Also drop the export on the highlighted tier: nothing outside the module reads
it, and the band's tiers are an implementation detail of `getEdgeZIndex`.

* fix(workflow): give the edge highlight one definition

The z-index elevation I added checked canvas selection only, while the edge
darkens for panel focus too — a block open in the editor lights its edges, and
those stayed depth-tiered, so an ordinary edge could still cut through the
highlight. The bug I set out to fix, on the path I had not covered.

The condition already existed in two places and the second one carries a comment
saying it must mirror the first exactly, because a knob checking fewer
conditions than the line leaves a dark line running into a light knob. Adding
the z would have made a third copy, and the finding here is what the third copy
gets you.

One predicate now, in `edge-highlight`, used by the line, the knobs, and the z.
The canvas subscribes to the panel store rather than reading `getState()`, since
the z has to be recomputed when the open block changes.
This commit is contained in:
Waleed
2026-08-12 15:27:18 -07:00
committed by GitHub
parent c58a6427f7
commit e56b28857c
6 changed files with 216 additions and 26 deletions
@@ -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'
@@ -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')
@@ -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,
@@ -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
@@ -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)
)
}
@@ -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,
@@ -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'
@@ -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
@@ -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,
}),
})
return {
...edge,
zIndex: baseZIndex,
data: {
...edge.data,
isSelected: selectedEdges.has(edgeContextId),
isSelected,
isConnectedToSelection,
isInsideLoop: Boolean(parentLoopId),
parentLoopId,
@@ -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
@@ -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,
@@ -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 }))
})
})
@@ -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
@@ -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)
}