From 4f55c088f8c0dcc058e9cf536d4d4142678db2d8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 14:40:44 -0700 Subject: [PATCH 1/3] fix(workflow): draw a highlighted edge over the ordinary ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 15 +++- .../src/canvas-layers.test.ts | 71 +++++++++++++++++++ .../workflow-renderer/src/canvas-layers.ts | 30 +++++++- 3 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 packages/workflow-renderer/src/canvas-layers.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 4e972785a87..1e5466dd4b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -4478,21 +4478,30 @@ 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: isSelected || isConnectedToSelection, + }) return { ...edge, zIndex: baseZIndex, data: { ...edge.data, - isSelected: selectedEdges.has(edgeContextId), + isSelected, isConnectedToSelection, isInsideLoop: Boolean(parentLoopId), parentLoopId, diff --git a/packages/workflow-renderer/src/canvas-layers.test.ts b/packages/workflow-renderer/src/canvas-layers.test.ts new file mode 100644 index 00000000000..0bd30cba13b --- /dev/null +++ b/packages/workflow-renderer/src/canvas-layers.test.ts @@ -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 })) + }) +}) diff --git a/packages/workflow-renderer/src/canvas-layers.ts b/packages/workflow-renderer/src/canvas-layers.ts index d1c308b3ba6..4a0b3abecb0 100644 --- a/packages/workflow-renderer/src/canvas-layers.ts +++ b/packages/workflow-renderer/src/canvas-layers.ts @@ -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. + */ +export 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) } From d5285f8cb5b02a9d8caf18f10d1624a1c1921608 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 14:43:32 -0700 Subject: [PATCH 2/3] fix(workflow): elevate the connection preview edge with the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- .../workspace/[workspaceId]/w/[workflowId]/workflow.tsx | 7 ++++++- packages/workflow-renderer/src/canvas-layers.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 1e5466dd4b2..d487f02be9d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -4529,7 +4529,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, diff --git a/packages/workflow-renderer/src/canvas-layers.ts b/packages/workflow-renderer/src/canvas-layers.ts index 4a0b3abecb0..a14c0b93405 100644 --- a/packages/workflow-renderer/src/canvas-layers.ts +++ b/packages/workflow-renderer/src/canvas-layers.ts @@ -37,7 +37,7 @@ const EDGE_Z_DEPTH_MAX = 18 * own endpoints; a line belongs behind cards, knobs and the action-bar swell * whether or not it is highlighted. */ -export const EDGE_Z_HIGHLIGHTED = 19 +const EDGE_Z_HIGHLIGHTED = 19 export const EDGE_Z_MAX = 20 export const BLOCK_Z_BASE = 21 export const CONTAINER_CHILD_Z_BASE = 1000 From 7496c23e9e7c01acc51516b6eb7b0b56fa23dfba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 15:20:26 -0700 Subject: [PATCH 3/3] fix(workflow): give the edge highlight one definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../workflow-block/workflow-block.tsx | 32 +++++++++------ .../workflow-edge/workflow-edge.tsx | 18 ++++++--- .../w/[workflowId]/utils/edge-highlight.ts | 40 +++++++++++++++++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 31 ++++++++++++-- 4 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/edge-highlight.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index ad3f911e2fd..406dd5f06a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -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') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 173e63e7d74..bc2194ae700 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/edge-highlight.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/edge-highlight.ts new file mode 100644 index 00000000000..4ca7639a05c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/edge-highlight.ts @@ -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) + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index d487f02be9d..a09579eea16 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -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 @@ -4493,7 +4502,15 @@ const WorkflowContent = React.memo( selectedNodeIdSet.has(edge.source) || selectedNodeIdSet.has(edge.target) const isSelected = selectedEdges.has(edgeContextId) const baseZIndex = getEdgeZIndex(containerNode ? (containerNode.zIndex ?? 0) : undefined, { - isHighlighted: isSelected || isConnectedToSelection, + isHighlighted: isEdgeHighlighted({ + isEndpointSelected: isConnectedToSelection, + isConnectedToEditor: isEdgeConnectedToEditor( + panelActiveTab === 'editor' ? editorOpenBlockId : null, + edge.source, + edge.target + ), + isEdgeSelected: isSelected, + }), }) return { @@ -4510,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