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 4e972785a87..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 @@ -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, 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..a14c0b93405 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. + */ +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) }