fix(subflows): subflow-child selection issues, subflow error logs (#3656)

* fix(subflows): subflow-child selection issues, subflow error logs

* address comments

* make selection context more reliable

* fix more multiselect issues

* fix shift selection ordering to work correctly

* fix more comments

* address more comments

* reuse helper
This commit is contained in:
Vikhyath Mondreti
2026-03-18 19:08:14 -07:00
committed by GitHub
parent 5332614a19
commit a2c08e19a8
9 changed files with 286 additions and 75 deletions
@@ -168,7 +168,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
)}
{/* Header Section — only interactive area for dragging */}
{/* Header Section */}
<div
onClick={() => setCurrentBlockId(id)}
className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
@@ -198,14 +198,15 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
</div>
{/*
* Subflow body background. Uses pointer-events: none so that edges rendered
* inside the subflow remain clickable. The subflow node wrapper also has
* pointer-events: none (set in workflow.tsx), so body-area clicks pass
* through to the pane. Subflow selection is done via the header above.
* Subflow body background. Captures clicks to select the subflow in the
* panel editor, matching the header click behavior. Child nodes and edges
* are rendered as sibling divs at the viewport level by ReactFlow (not as
* DOM children), so enabling pointer events here doesn't block them.
*/}
<div
className='absolute inset-0 top-[44px] rounded-b-[8px]'
style={{ pointerEvents: 'none' }}
className='workflow-drag-handle absolute inset-0 top-[44px] cursor-grab rounded-b-[8px] [&:active]:cursor-grabbing'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
onClick={() => setCurrentBlockId(id)}
/>
{!isPreview && (
@@ -221,3 +221,68 @@ export function resolveParentChildSelectionConflicts(
return hasConflict ? resolved : nodes
}
export function getNodeSelectionContextId(
node: Pick<Node, 'id' | 'parentId'>,
blocks: Record<string, { data?: { parentId?: string } }>
): string | null {
return node.parentId || blocks[node.id]?.data?.parentId || null
}
export function getEdgeSelectionContextId(
edge: Pick<Edge, 'source' | 'target'>,
nodes: Array<Pick<Node, 'id' | 'parentId'>>,
blocks: Record<string, { data?: { parentId?: string } }>
): string | null {
const sourceNode = nodes.find((node) => node.id === edge.source)
const targetNode = nodes.find((node) => node.id === edge.target)
const sourceContextId = sourceNode ? getNodeSelectionContextId(sourceNode, blocks) : null
const targetContextId = targetNode ? getNodeSelectionContextId(targetNode, blocks) : null
if (sourceContextId) return sourceContextId
if (targetContextId) return targetContextId
return null
}
export function resolveSelectionContextConflicts(
nodes: Node[],
blocks: Record<string, { data?: { parentId?: string } }>,
preferredContextId?: string | null
): Node[] {
const selectedNodes = nodes.filter((node) => node.selected)
if (selectedNodes.length <= 1) return nodes
const allowedContextId =
preferredContextId !== undefined
? preferredContextId
: getNodeSelectionContextId(selectedNodes[0], blocks)
let hasConflict = false
const resolved = nodes.map((node) => {
if (!node.selected) return node
const contextId = getNodeSelectionContextId(node, blocks)
if (contextId !== allowedContextId) {
hasConflict = true
return { ...node, selected: false }
}
return node
})
return hasConflict ? resolved : nodes
}
export function resolveSelectionConflicts(
nodes: Node[],
blocks: Record<string, { data?: { parentId?: string } }>,
preferredNodeId?: string
): Node[] {
const afterParentChild = resolveParentChildSelectionConflicts(nodes, blocks)
const preferredContextId =
preferredNodeId !== undefined
? afterParentChild.find((n) => n.id === preferredNodeId && n.selected)
? getNodeSelectionContextId(afterParentChild.find((n) => n.id === preferredNodeId)!, blocks)
: undefined
: undefined
return resolveSelectionContextConflicts(afterParentChild, blocks, preferredContextId)
}
@@ -59,11 +59,13 @@ import {
filterProtectedBlocks,
getClampedPositionForNode,
getDescendantBlockIds,
getEdgeSelectionContextId,
getNodeSelectionContextId,
getWorkflowLockToggleIds,
isBlockProtected,
isEdgeProtected,
isInEditableElement,
resolveParentChildSelectionConflicts,
resolveSelectionConflicts,
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useSocket } from '@/app/workspace/providers/socket-provider'
@@ -168,16 +170,17 @@ function mapEdgesByNode(edges: Edge[], nodeIds: Set<string>): Map<string, Edge[]
/**
* Syncs the panel editor with the current selection state.
* Shows block details when exactly one block is selected, clears otherwise.
* Shows the last selected block in the panel. Clears when nothing is selected.
*/
function syncPanelWithSelection(selectedIds: string[]) {
const { currentBlockId, clearCurrentBlock, setCurrentBlockId } = usePanelEditorStore.getState()
if (selectedIds.length === 1 && selectedIds[0] !== currentBlockId) {
setCurrentBlockId(selectedIds[0])
} else if (selectedIds.length === 0 && currentBlockId) {
clearCurrentBlock()
} else if (selectedIds.length > 1 && currentBlockId) {
clearCurrentBlock()
if (selectedIds.length === 0) {
if (currentBlockId) clearCurrentBlock()
} else {
const lastSelectedId = selectedIds[selectedIds.length - 1]
if (lastSelectedId !== currentBlockId) {
setCurrentBlockId(lastSelectedId)
}
}
}
@@ -246,7 +249,6 @@ const WorkflowContent = React.memo(
const [selectedEdges, setSelectedEdges] = useState<SelectedEdgesMap>(new Map())
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
const canvasContainerRef = useRef<HTMLDivElement>(null)
const selectedIdsRef = useRef<string[] | null>(null)
const embeddedFitFrameRef = useRef<number | null>(null)
const hasCompletedInitialEmbeddedFitRef = useRef(false)
const canvasMode = useCanvasModeStore((state) => state.mode)
@@ -2477,6 +2479,16 @@ const WorkflowContent = React.memo(
// Local state for nodes - allows smooth drag without store updates on every frame
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
const selectedNodeIds = useMemo(
() => displayNodes.filter((node) => node.selected).map((node) => node.id),
[displayNodes]
)
const selectedNodeIdsKey = selectedNodeIds.join(',')
useEffect(() => {
syncPanelWithSelection(selectedNodeIds)
}, [selectedNodeIdsKey])
useEffect(() => {
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection
if (pendingSelection && pendingSelection.length > 0) {
@@ -2488,10 +2500,8 @@ const WorkflowContent = React.memo(
...node,
selected: pendingSet.has(node.id),
}))
const resolved = resolveParentChildSelectionConflicts(withSelection, blocks)
const resolved = resolveSelectionConflicts(withSelection, blocks)
setDisplayNodes(resolved)
const selectedIds = resolved.filter((node) => node.selected).map((node) => node.id)
syncPanelWithSelection(selectedIds)
return
}
@@ -2709,19 +2719,20 @@ const WorkflowContent = React.memo(
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
selectedIdsRef.current = null
setDisplayNodes((nds) => {
const updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some((c) => c.type === 'select')
const hasSelectionChange = changes.some((c) => c.type === 'select')
setDisplayNodes((currentNodes) => {
const updated = applyNodeChanges(changes, currentNodes)
if (!hasSelectionChange) return updated
const resolved = resolveParentChildSelectionConflicts(updated, blocks)
selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id)
return resolved
const preferredNodeId = [...changes]
.reverse()
.find(
(change): change is NodeChange & { id: string; selected: boolean } =>
change.type === 'select' && 'selected' in change && change.selected === true
)?.id
return resolveSelectionConflicts(updated, blocks, preferredNodeId)
})
const selectedIds = selectedIdsRef.current as string[] | null
if (selectedIds !== null) {
syncPanelWithSelection(selectedIds)
}
// Handle position changes (e.g., from keyboard arrow key movement)
// Update container dimensions when child nodes are moved and persist to backend
@@ -3160,7 +3171,10 @@ const WorkflowContent = React.memo(
parentId: currentParentId,
})
// Capture all selected nodes' positions for multi-node undo/redo
// Capture all selected nodes' positions for multi-node undo/redo.
// Also include the dragged node itself — during shift+click+drag, ReactFlow
// may have toggled (deselected) the node before drag starts, so it might not
// appear in the selected set yet.
const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected)
multiNodeDragStartRef.current.clear()
@@ -3174,6 +3188,33 @@ const WorkflowContent = React.memo(
})
}
})
if (!multiNodeDragStartRef.current.has(node.id)) {
multiNodeDragStartRef.current.set(node.id, {
x: node.position.x,
y: node.position.y,
parentId: currentParentId ?? undefined,
})
}
// When shift+clicking an already-selected node, ReactFlow toggles (deselects)
// it via onNodesChange before drag starts. Re-select the dragged node so all
// previously selected nodes move together as a group — but only if the
// deselection wasn't from a parent-child conflict (e.g. dragging a child
// when its parent subflow is selected).
const draggedNodeInSelected = allNodes.find((n) => n.id === node.id)
if (draggedNodeInSelected && !draggedNodeInSelected.selected && selectedNodes.length > 0) {
const draggedParentId = blocks[node.id]?.data?.parentId
const parentIsSelected =
draggedParentId && selectedNodes.some((n) => n.id === draggedParentId)
const contextMismatch =
getNodeSelectionContextId(draggedNodeInSelected, blocks) !==
getNodeSelectionContextId(selectedNodes[0], blocks)
if (!parentIsSelected && !contextMismatch) {
setDisplayNodes((currentNodes) =>
currentNodes.map((n) => (n.id === node.id ? { ...n, selected: true } : n))
)
}
}
},
[blocks, setDragStartPosition, getNodes, setPotentialParentId]
)
@@ -3453,7 +3494,7 @@ const WorkflowContent = React.memo(
})
// Apply visual deselection of children
setDisplayNodes((allNodes) => resolveParentChildSelectionConflicts(allNodes, blocks))
setDisplayNodes((allNodes) => resolveSelectionConflicts(allNodes, blocks))
},
[blocks]
)
@@ -3604,19 +3645,25 @@ const WorkflowContent = React.memo(
/**
* Handles node click to select the node in ReactFlow.
* Parent-child conflict resolution happens automatically in onNodesChange.
* Uses the controlled display node state so parent-child conflicts are resolved
* consistently for click, shift-click, and marquee selection.
*/
const handleNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => {
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
setNodes((nodes) =>
nodes.map((n) => ({
...n,
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
setDisplayNodes((currentNodes) => {
const updated = currentNodes.map((currentNode) => ({
...currentNode,
selected: isMultiSelect
? currentNode.id === node.id
? true
: currentNode.selected
: currentNode.id === node.id,
}))
)
return resolveSelectionConflicts(updated, blocks, isMultiSelect ? node.id : undefined)
})
},
[setNodes]
[blocks]
)
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
@@ -3624,16 +3671,10 @@ const WorkflowContent = React.memo(
(event: React.MouseEvent, edge: any) => {
event.stopPropagation() // Prevent bubbling
// Determine if edge is inside a loop by checking its source/target nodes
const sourceNode = getNodes().find((n) => n.id === edge.source)
const targetNode = getNodes().find((n) => n.id === edge.target)
// An edge is inside a loop if either source or target has a parent
// If source and target have different parents, prioritize source's parent
const parentLoopId = sourceNode?.parentId || targetNode?.parentId
// Create a unique identifier that combines edge ID and parent context
const contextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}`
const contextId = `${edge.id}${(() => {
const selectionContextId = getEdgeSelectionContextId(edge, getNodes(), blocks)
return selectionContextId ? `-${selectionContextId}` : ''
})()}`
if (event.shiftKey) {
// Shift-click: toggle edge in selection
@@ -3651,7 +3692,7 @@ const WorkflowContent = React.memo(
setSelectedEdges(new Map([[contextId, edge.id]]))
}
},
[getNodes]
[blocks, getNodes]
)
/** Stable delete handler to avoid creating new function references per edge. */
+51
View File
@@ -92,6 +92,57 @@ describe('DAGBuilder disabled subflow validation', () => {
// Should not throw - loop is effectively disabled since all inner blocks are disabled
expect(() => builder.build(workflow)).not.toThrow()
})
it('does not mutate serialized loop config nodes during DAG build', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('loop-1', BlockType.LOOP),
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
],
connections: [{ source: 'start', target: 'loop-1' }],
loops: {
'loop-1': {
id: 'loop-1',
nodes: ['inner-block'],
iterations: 3,
},
},
parallels: {},
}
const builder = new DAGBuilder()
builder.build(workflow)
expect(workflow.loops?.['loop-1']?.nodes).toEqual(['inner-block'])
})
it('does not mutate serialized parallel config nodes during DAG build', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('parallel-1', BlockType.PARALLEL),
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
],
connections: [{ source: 'start', target: 'parallel-1' }],
loops: {},
parallels: {
'parallel-1': {
id: 'parallel-1',
nodes: ['inner-block'],
count: 2,
parallelType: 'count',
},
},
}
const builder = new DAGBuilder()
builder.build(workflow)
expect(workflow.parallels?.['parallel-1']?.nodes).toEqual(['inner-block'])
})
})
describe('DAGBuilder nested parallel support', () => {
+9 -5
View File
@@ -113,13 +113,19 @@ export class DAGBuilder {
private initializeConfigs(workflow: SerializedWorkflow, dag: DAG): void {
if (workflow.loops) {
for (const [loopId, loopConfig] of Object.entries(workflow.loops)) {
dag.loopConfigs.set(loopId, loopConfig)
dag.loopConfigs.set(loopId, {
...loopConfig,
nodes: [...(loopConfig.nodes ?? [])],
})
}
}
if (workflow.parallels) {
for (const [parallelId, parallelConfig] of Object.entries(workflow.parallels)) {
dag.parallelConfigs.set(parallelId, parallelConfig)
dag.parallelConfigs.set(parallelId, {
...parallelConfig,
nodes: [...(parallelConfig.nodes ?? [])],
})
}
}
}
@@ -150,9 +156,7 @@ export class DAGBuilder {
if (!sentinelStartNode) return
if (!nodes || nodes.length === 0) {
throw new Error(
`${type} has no blocks inside. Add at least one block to the ${type.toLowerCase()}.`
)
return
}
const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) =>
+6 -9
View File
@@ -8,24 +8,21 @@ const logger = createLogger('LoopConstructor')
export class LoopConstructor {
execute(dag: DAG, reachableBlocks: Set<string>): void {
for (const [loopId, loopConfig] of dag.loopConfigs) {
const loopNodes = loopConfig.nodes
if (loopNodes.length === 0) {
if (!reachableBlocks.has(loopId)) {
continue
}
if (!this.hasReachableNodes(loopNodes, reachableBlocks)) {
continue
const loopNodes = loopConfig.nodes
const hasReachableChildren = loopNodes.some((nodeId) => reachableBlocks.has(nodeId))
if (!hasReachableChildren) {
loopConfig.nodes = []
}
this.createSentinelPair(dag, loopId)
}
}
private hasReachableNodes(loopNodes: string[], reachableBlocks: Set<string>): boolean {
return loopNodes.some((nodeId) => reachableBlocks.has(nodeId))
}
private createSentinelPair(dag: DAG, loopId: string): void {
const startId = buildSentinelStartId(loopId)
const endId = buildSentinelEndId(loopId)
@@ -11,24 +11,21 @@ const logger = createLogger('ParallelConstructor')
export class ParallelConstructor {
execute(dag: DAG, reachableBlocks: Set<string>): void {
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
const parallelNodes = parallelConfig.nodes
if (parallelNodes.length === 0) {
if (!reachableBlocks.has(parallelId)) {
continue
}
if (!this.hasReachableNodes(parallelNodes, reachableBlocks)) {
continue
const parallelNodes = parallelConfig.nodes
const hasReachableChildren = parallelNodes.some((nodeId) => reachableBlocks.has(nodeId))
if (!hasReachableChildren) {
parallelConfig.nodes = []
}
this.createSentinelPair(dag, parallelId)
}
}
private hasReachableNodes(parallelNodes: string[], reachableBlocks: Set<string>): boolean {
return parallelNodes.some((nodeId) => reachableBlocks.has(nodeId))
}
private createSentinelPair(dag: DAG, parallelId: string): void {
const startId = buildParallelSentinelStartId(parallelId)
const endId = buildParallelSentinelEndId(parallelId)
+41
View File
@@ -56,6 +56,29 @@ export class LoopOrchestrator {
if (!loopConfig) {
throw new Error(`Loop config not found: ${loopId}`)
}
if (loopConfig.nodes.length === 0) {
const errorMessage =
'Loop has no executable blocks inside. Add or enable at least one block in the loop.'
const loopType = loopConfig.loopType || 'for'
logger.error(errorMessage, { loopId })
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {})
const errorScope: LoopScope = {
iteration: 0,
maxIterations: 0,
loopType,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
condition: 'false',
validationError: errorMessage,
}
if (!ctx.loopExecutions) {
ctx.loopExecutions = new Map()
}
ctx.loopExecutions.set(loopId, errorScope)
throw new Error(errorMessage)
}
const scope: LoopScope = {
iteration: 0,
currentIterationOutputs: new Map(),
@@ -93,6 +116,24 @@ export class LoopOrchestrator {
case 'forEach': {
scope.loopType = 'forEach'
if (
loopConfig.forEachItems === undefined ||
loopConfig.forEachItems === null ||
loopConfig.forEachItems === ''
) {
const errorMessage =
'ForEach loop collection is empty. Provide an array or a reference that resolves to a collection.'
logger.error(errorMessage, { loopId })
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {
forEachItems: loopConfig.forEachItems,
})
scope.items = []
scope.maxIterations = 0
scope.validationError = errorMessage
scope.condition = buildLoopIndexCondition(0)
ctx.loopExecutions?.set(loopId, scope)
throw new Error(errorMessage)
}
let items: any[]
try {
items = resolveArrayInput(ctx, loopConfig.forEachItems, this.resolver)
+16 -2
View File
@@ -57,6 +57,15 @@ export class ParallelOrchestrator {
throw new Error(`Parallel config not found: ${parallelId}`)
}
if (terminalNodesCount === 0 || parallelConfig.nodes.length === 0) {
const errorMessage =
'Parallel has no executable blocks inside. Add or enable at least one block in the parallel.'
logger.error(errorMessage, { parallelId })
await this.addParallelErrorLog(ctx, parallelId, errorMessage, {})
this.setErrorScope(ctx, parallelId, errorMessage)
throw new Error(errorMessage)
}
let items: any[] | undefined
let branchCount: number
let isEmpty = false
@@ -67,7 +76,10 @@ export class ParallelOrchestrator {
items = resolved.items
isEmpty = resolved.isEmpty ?? false
} catch (error) {
const errorMessage = `Parallel Items did not resolve: ${error instanceof Error ? error.message : String(error)}`
const baseErrorMessage = error instanceof Error ? error.message : String(error)
const errorMessage = baseErrorMessage.startsWith('Parallel collection distribution is empty')
? baseErrorMessage
: `Parallel Items did not resolve: ${baseErrorMessage}`
logger.error(errorMessage, { parallelId, distribution: parallelConfig.distribution })
await this.addParallelErrorLog(ctx, parallelId, errorMessage, {
distribution: parallelConfig.distribution,
@@ -258,7 +270,9 @@ export class ParallelOrchestrator {
config.distribution === null ||
config.distribution === ''
) {
return []
throw new Error(
'Parallel collection distribution is empty. Provide an array or a reference that resolves to a collection.'
)
}
return resolveArrayInput(ctx, config.distribution, this.resolver)
}