fix(subflow): prevent auto-connect across subflow edges with keyboard shortcut block additions, make positioning for auto-drop smarter (#2489)

* fix(subflow): prevent auto-connect across subflow edges with keyboard shortcut block additions, make positioning for auto-drop smarter

* stronger typing
This commit is contained in:
Waleed
2025-12-19 18:31:29 -08:00
committed by GitHub
parent 50c1c6775b
commit 93fe68785e

View File

@@ -18,6 +18,7 @@ import { useShallow } from 'zustand/react/shallow'
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access' import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import type { OAuthProvider } from '@/lib/oauth' import type { OAuthProvider } from '@/lib/oauth'
import { DEFAULT_HORIZONTAL_SPACING } from '@/lib/workflows/autolayout/constants'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -32,6 +33,7 @@ import {
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors' import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block' import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal' import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
@@ -523,7 +525,7 @@ const WorkflowContent = React.memo(() => {
useEffect(() => { useEffect(() => {
const handleRemoveFromSubflow = (event: Event) => { const handleRemoveFromSubflow = (event: Event) => {
const customEvent = event as CustomEvent<{ blockId: string }> const customEvent = event as CustomEvent<{ blockId: string }>
const { blockId } = customEvent.detail || ({} as any) const blockId = customEvent.detail?.blockId
if (!blockId) return if (!blockId) return
try { try {
@@ -602,6 +604,152 @@ const WorkflowContent = React.memo(() => {
return 'source' return 'source'
}, []) }, [])
/** Creates a standardized edge object for workflow connections. */
const createEdgeObject = useCallback(
(sourceId: string, targetId: string, sourceHandle: string): Edge => ({
id: crypto.randomUUID(),
source: sourceId,
target: targetId,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}),
[]
)
/** Gets the appropriate start handle for a container node (loop or parallel). */
const getContainerStartHandle = useCallback(
(containerId: string): string => {
const containerNode = getNodes().find((n) => n.id === containerId)
return (containerNode?.data as SubflowNodeData)?.kind === 'loop'
? 'loop-start-source'
: 'parallel-start-source'
},
[getNodes]
)
/** Finds the closest non-response block to a position within a set of blocks. */
const findClosestBlockInSet = useCallback(
(
candidateBlocks: { id: string; type: string; position: { x: number; y: number } }[],
targetPosition: { x: number; y: number }
): { id: string; type: string; position: { x: number; y: number } } | undefined => {
return candidateBlocks
.filter((b) => b.type !== 'response')
.map((b) => ({
block: b,
distance: Math.sqrt(
(b.position.x - targetPosition.x) ** 2 + (b.position.y - targetPosition.y) ** 2
),
}))
.sort((a, b) => a.distance - b.distance)[0]?.block
},
[]
)
/**
* Attempts to create an auto-connect edge for a new block being added.
* Returns the edge object if auto-connect should occur, or undefined otherwise.
*
* @param position - The position where the new block will be placed
* @param targetBlockId - The ID of the new block being added
* @param options - Configuration for auto-connect behavior
*/
const tryCreateAutoConnectEdge = useCallback(
(
position: { x: number; y: number },
targetBlockId: string,
options: {
blockType: string
enableTriggerMode?: boolean
targetParentId?: string | null
existingChildBlocks?: { id: string; type: string; position: { x: number; y: number } }[]
containerId?: string
}
): Edge | undefined => {
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
if (!isAutoConnectEnabled) return undefined
// Don't auto-connect starter or annotation-only blocks
if (options.blockType === 'starter' || isAnnotationOnlyBlock(options.blockType)) {
return undefined
}
// Check if target is a trigger block
const targetBlockConfig = getBlock(options.blockType)
const isTargetTrigger =
options.enableTriggerMode || targetBlockConfig?.category === 'triggers'
if (isTargetTrigger) return undefined
// Case 1: Adding block inside a container with existing children
if (options.existingChildBlocks && options.existingChildBlocks.length > 0) {
const closestBlock = findClosestBlockInSet(options.existingChildBlocks, position)
if (closestBlock) {
const sourceHandle = determineSourceHandle({
id: closestBlock.id,
type: closestBlock.type,
})
return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle)
}
return undefined
}
// Case 2: Adding block inside an empty container - connect from container start
if (
options.containerId &&
(!options.existingChildBlocks || options.existingChildBlocks.length === 0)
) {
const startHandle = getContainerStartHandle(options.containerId)
return createEdgeObject(options.containerId, targetBlockId, startHandle)
}
// Case 3: Adding block at root level - use findClosestOutput
const closestBlock = findClosestOutput(position)
if (!closestBlock) return undefined
// Don't create cross-container edges
const closestBlockParentId = blocks[closestBlock.id]?.data?.parentId
if (closestBlockParentId && !options.targetParentId) {
return undefined
}
const sourceHandle = determineSourceHandle(closestBlock)
return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle)
},
[
blocks,
findClosestOutput,
determineSourceHandle,
createEdgeObject,
getContainerStartHandle,
findClosestBlockInSet,
]
)
/**
* Checks if adding a trigger block would violate constraints and shows notification if so.
* @returns true if validation failed (caller should return early), false if ok to proceed
*/
const checkTriggerConstraints = useCallback(
(blockType: string): boolean => {
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
if (issue) {
const message =
issue.issue === 'legacy'
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.`
addNotification({
level: 'error',
message,
workflowId: activeWorkflowId || undefined,
})
return true
}
return false
},
[blocks, addNotification, activeWorkflowId]
)
/** /**
* Shared handler for drops of toolbar items onto the workflow canvas. * Shared handler for drops of toolbar items onto the workflow canvas.
* *
@@ -630,21 +778,10 @@ const WorkflowContent = React.memo(() => {
const baseName = data.type === 'loop' ? 'Loop' : 'Parallel' const baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks) const name = getUniqueBlockName(baseName, blocks)
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
let autoConnectEdge blockType: data.type,
if (isAutoConnectEnabled) { targetParentId: null,
const closestBlock = findClosestOutput(position) })
if (closestBlock) {
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle: determineSourceHandle(closestBlock),
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
addBlock( addBlock(
id, id,
@@ -652,8 +789,8 @@ const WorkflowContent = React.memo(() => {
name, name,
position, position,
{ {
width: 500, width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: 300, height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
type: 'subflowNode', type: 'subflowNode',
}, },
undefined, undefined,
@@ -675,12 +812,7 @@ const WorkflowContent = React.memo(() => {
const id = crypto.randomUUID() const id = crypto.randomUUID()
// Prefer semantic default names for triggers; then ensure unique numbering centrally // Prefer semantic default names for triggers; then ensure unique numbering centrally
const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type) const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type)
const baseName = const baseName = defaultTriggerNameDrop || blockConfig.name
data.type === 'loop'
? 'Loop'
: data.type === 'parallel'
? 'Parallel'
: defaultTriggerNameDrop || blockConfig!.name
const name = getUniqueBlockName(baseName, blocks) const name = getUniqueBlockName(baseName, blocks)
if (containerInfo) { if (containerInfo) {
@@ -712,72 +844,18 @@ const WorkflowContent = React.memo(() => {
estimateBlockDimensions(data.type) estimateBlockDimensions(data.type)
) )
// Capture existing child blocks before adding the new one // Capture existing child blocks for auto-connect
const existingChildBlocks = Object.values(blocks).filter( const existingChildBlocks = Object.values(blocks)
(b) => b.data?.parentId === containerInfo.loopId .filter((b) => b.data?.parentId === containerInfo.loopId)
) .map((b) => ({ id: b.id, type: b.type, position: b.position }))
// Auto-connect logic for blocks inside containers const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, {
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled blockType: data.type,
let autoConnectEdge enableTriggerMode: data.enableTriggerMode,
if ( targetParentId: containerInfo.loopId,
isAutoConnectEnabled && existingChildBlocks,
data.type !== 'starter' && containerId: containerInfo.loopId,
!isAnnotationOnlyBlock(data.type) })
) {
if (existingChildBlocks.length > 0) {
// Connect to the nearest existing child block within the container
// Filter out response blocks since they have no outgoing handles
const closestBlock = existingChildBlocks
.filter((b) => b.type !== 'response')
.map((b) => ({
block: b,
distance: Math.sqrt(
(b.position.x - relativePosition.x) ** 2 +
(b.position.y - relativePosition.y) ** 2
),
}))
.sort((a, b) => a.distance - b.distance)[0]?.block
if (closestBlock) {
// Don't create edges into trigger blocks or annotation blocks
const targetBlockConfig = getBlock(data.type)
const isTargetTrigger =
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle({
id: closestBlock.id,
type: closestBlock.type,
})
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
} else {
// No existing children: connect from the container's start handle to the moved node
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
const startSourceHandle =
(containerNode?.data as any)?.kind === 'loop'
? 'loop-start-source'
: 'parallel-start-source'
autoConnectEdge = {
id: crypto.randomUUID(),
source: containerInfo.loopId,
target: id,
sourceHandle: startSourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
// Add block with parent info AND autoConnectEdge (atomic operation) // Add block with parent info AND autoConnectEdge (atomic operation)
addBlock( addBlock(
@@ -799,49 +877,13 @@ const WorkflowContent = React.memo(() => {
resizeLoopNodesWrapper() resizeLoopNodesWrapper()
} else { } else {
// Centralized trigger constraints // Centralized trigger constraints
const dropIssue = TriggerUtils.getTriggerAdditionIssue(blocks, data.type) if (checkTriggerConstraints(data.type)) return
if (dropIssue) {
const message =
dropIssue.issue === 'legacy'
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
: `A workflow can only have one ${dropIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
addNotification({
level: 'error',
message,
workflowId: activeWorkflowId || undefined,
})
return
}
// Regular auto-connect logic const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled blockType: data.type,
let autoConnectEdge enableTriggerMode: data.enableTriggerMode,
if ( targetParentId: null,
isAutoConnectEnabled && })
data.type !== 'starter' &&
!isAnnotationOnlyBlock(data.type)
) {
const closestBlock = findClosestOutput(position)
if (closestBlock) {
// Don't create edges into trigger blocks or annotation blocks
const targetBlockConfig = getBlock(data.type)
const isTargetTrigger =
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
}
// Regular canvas drop with auto-connect edge // Regular canvas drop with auto-connect edge
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab) // Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
@@ -864,14 +906,13 @@ const WorkflowContent = React.memo(() => {
}, },
[ [
blocks, blocks,
getNodes,
findClosestOutput,
determineSourceHandle,
isPointInLoopNode, isPointInLoopNode,
resizeLoopNodesWrapper, resizeLoopNodesWrapper,
addBlock, addBlock,
addNotification, addNotification,
activeWorkflowId, activeWorkflowId,
tryCreateAutoConnectEdge,
checkTriggerConstraints,
] ]
) )
@@ -888,44 +929,73 @@ const WorkflowContent = React.memo(() => {
if (!type) return if (!type) return
if (type === 'connectionBlock') return if (type === 'connectionBlock') return
// Calculate smart position - to the right of existing root-level blocks
const calculateSmartPosition = (): { x: number; y: number } => {
// Get all root-level blocks (no parentId)
const rootBlocks = Object.values(blocks).filter((b) => !b.data?.parentId)
if (rootBlocks.length === 0) {
// No blocks yet, use viewport center
return screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
})
}
// Find the rightmost block
let maxRight = Number.NEGATIVE_INFINITY
let rightmostBlockY = 0
for (const block of rootBlocks) {
const blockWidth =
block.type === 'loop' || block.type === 'parallel'
? block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
: BLOCK_DIMENSIONS.FIXED_WIDTH
const blockRight = block.position.x + blockWidth
if (blockRight > maxRight) {
maxRight = blockRight
rightmostBlockY = block.position.y
}
}
// Position to the right with autolayout spacing
const position = {
x: maxRight + DEFAULT_HORIZONTAL_SPACING,
y: rightmostBlockY,
}
// Ensure position doesn't overlap any container
let container = isPointInLoopNode(position)
while (container) {
position.x =
container.loopPosition.x + container.dimensions.width + DEFAULT_HORIZONTAL_SPACING
container = isPointInLoopNode(position)
}
return position
}
const basePosition = calculateSmartPosition()
// Special handling for container nodes (loop or parallel) // Special handling for container nodes (loop or parallel)
if (type === 'loop' || type === 'parallel') { if (type === 'loop' || type === 'parallel') {
const id = crypto.randomUUID() const id = crypto.randomUUID()
const baseName = type === 'loop' ? 'Loop' : 'Parallel' const baseName = type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks) const name = getUniqueBlockName(baseName, blocks)
const centerPosition = screenToFlowPosition({ const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
x: window.innerWidth / 2, blockType: type,
y: window.innerHeight / 2, targetParentId: null,
}) })
// Auto-connect logic for container nodes
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled) {
const closestBlock = findClosestOutput(centerPosition)
if (closestBlock) {
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
// Add the container node with default dimensions and auto-connect edge // Add the container node with default dimensions and auto-connect edge
addBlock( addBlock(
id, id,
type, type,
name, name,
centerPosition, basePosition,
{ {
width: 500, width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: 300, height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
type: 'subflowNode', type: 'subflowNode',
}, },
undefined, undefined,
@@ -942,11 +1012,8 @@ const WorkflowContent = React.memo(() => {
return return
} }
// Calculate the center position of the viewport // Check trigger constraints first
const centerPosition = screenToFlowPosition({ if (checkTriggerConstraints(type)) return
x: window.innerWidth / 2,
y: window.innerHeight / 2,
})
// Create a new block with a unique ID // Create a new block with a unique ID
const id = crypto.randomUUID() const id = crypto.randomUUID()
@@ -955,51 +1022,11 @@ const WorkflowContent = React.memo(() => {
const baseName = defaultTriggerName || blockConfig.name const baseName = defaultTriggerName || blockConfig.name
const name = getUniqueBlockName(baseName, blocks) const name = getUniqueBlockName(baseName, blocks)
// Auto-connect logic const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled blockType: type,
let autoConnectEdge enableTriggerMode,
if (isAutoConnectEnabled && type !== 'starter' && !isAnnotationOnlyBlock(type)) { targetParentId: null,
const closestBlock = findClosestOutput(centerPosition) })
logger.info('Closest block found:', closestBlock)
if (closestBlock) {
// Don't create edges into trigger blocks or annotation blocks
const targetBlockConfig = blockConfig
const isTargetTrigger = enableTriggerMode || targetBlockConfig?.category === 'triggers'
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
logger.info('Auto-connect edge created:', autoConnectEdge)
} else {
logger.info('Skipping auto-connect into trigger block', {
target: type,
})
}
}
}
// Centralized trigger constraints
const additionIssue = TriggerUtils.getTriggerAdditionIssue(blocks, type)
if (additionIssue) {
const message =
additionIssue.issue === 'legacy'
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
: `A workflow can only have one ${additionIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
addNotification({
level: 'error',
message,
workflowId: activeWorkflowId || undefined,
})
return
}
// Add the block to the workflow with auto-connect edge // Add the block to the workflow with auto-connect edge
// Enable trigger mode if this is a trigger-capable block from the triggers tab // Enable trigger mode if this is a trigger-capable block from the triggers tab
@@ -1007,7 +1034,7 @@ const WorkflowContent = React.memo(() => {
id, id,
type, type,
name, name,
centerPosition, basePosition,
undefined, undefined,
undefined, undefined,
undefined, undefined,
@@ -1028,11 +1055,12 @@ const WorkflowContent = React.memo(() => {
screenToFlowPosition, screenToFlowPosition,
blocks, blocks,
addBlock, addBlock,
findClosestOutput, tryCreateAutoConnectEdge,
determineSourceHandle, isPointInLoopNode,
effectivePermissions.canEdit, effectivePermissions.canEdit,
addNotification, addNotification,
activeWorkflowId, activeWorkflowId,
checkTriggerConstraints,
]) ])
/** /**
@@ -1223,12 +1251,12 @@ const WorkflowContent = React.memo(() => {
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId) const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
if ( if (
containerNode?.type === 'subflowNode' && containerNode?.type === 'subflowNode' &&
(containerNode.data as any)?.kind === 'loop' (containerNode.data as SubflowNodeData)?.kind === 'loop'
) { ) {
containerElement.classList.add('loop-node-drag-over') containerElement.classList.add('loop-node-drag-over')
} else if ( } else if (
containerNode?.type === 'subflowNode' && containerNode?.type === 'subflowNode' &&
(containerNode.data as any)?.kind === 'parallel' (containerNode.data as SubflowNodeData)?.kind === 'parallel'
) { ) {
containerElement.classList.add('parallel-node-drag-over') containerElement.classList.add('parallel-node-drag-over')
} }
@@ -1427,8 +1455,8 @@ const WorkflowContent = React.memo(() => {
data: { data: {
...block.data, ...block.data,
name: block.name, name: block.name,
width: block.data?.width || 500, width: block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: block.data?.height || 300, height: block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
kind: block.type === 'loop' ? 'loop' : 'parallel', kind: block.type === 'loop' ? 'loop' : 'parallel',
}, },
}) })
@@ -1487,8 +1515,8 @@ const WorkflowContent = React.memo(() => {
}, },
// Include dynamic dimensions for container resizing calculations (must match rendered size) // Include dynamic dimensions for container resizing calculations (must match rendered size)
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions // Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
width: 250, // Standard width for both block types width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(block.height || 100, 100), // Use calculated height with minimum height: Math.max(block.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT),
}) })
}) })
@@ -1575,7 +1603,7 @@ const WorkflowContent = React.memo(() => {
/** /**
* Effect to resize loops when nodes change (add/remove/position change). * Effect to resize loops when nodes change (add/remove/position change).
* Runs on structural changes only - not during drag (position-only changes). * Runs on structural changes only - not during drag (position-only changes).
* Skips during loading to avoid unnecessary work. * Skips during loading.
*/ */
useEffect(() => { useEffect(() => {
// Skip during initial render when nodes aren't loaded yet or workflow not ready // Skip during initial render when nodes aren't loaded yet or workflow not ready
@@ -1797,12 +1825,15 @@ const WorkflowContent = React.memo(() => {
const containerAbsolutePos = getNodeAbsolutePosition(n.id) const containerAbsolutePos = getNodeAbsolutePosition(n.id)
// Get dimensions based on node type (must match actual rendered dimensions) // Get dimensions based on node type (must match actual rendered dimensions)
const nodeWidth = node.type === 'subflowNode' ? node.data?.width || 500 : 250 // All workflow blocks use w-[250px] in workflow-block.tsx const nodeWidth =
node.type === 'subflowNode'
? node.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
: BLOCK_DIMENSIONS.FIXED_WIDTH
const nodeHeight = const nodeHeight =
node.type === 'subflowNode' node.type === 'subflowNode'
? node.data?.height || 300 ? node.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
: Math.max(node.height || 100, 100) // Use actual node height with minimum 100 : Math.max(node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT)
// Check intersection using absolute coordinates // Check intersection using absolute coordinates
const nodeRect = { const nodeRect = {
@@ -1814,9 +1845,10 @@ const WorkflowContent = React.memo(() => {
const containerRect = { const containerRect = {
left: containerAbsolutePos.x, left: containerAbsolutePos.x,
right: containerAbsolutePos.x + (n.data?.width || 500), right: containerAbsolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
top: containerAbsolutePos.y, top: containerAbsolutePos.y,
bottom: containerAbsolutePos.y + (n.data?.height || 300), bottom:
containerAbsolutePos.y + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
} }
// Check intersection with absolute coordinates for accurate detection // Check intersection with absolute coordinates for accurate detection
@@ -1832,7 +1864,9 @@ const WorkflowContent = React.memo(() => {
container: n, container: n,
depth: getNodeDepth(n.id), depth: getNodeDepth(n.id),
// Calculate size for secondary sorting // Calculate size for secondary sorting
size: (n.data?.width || 500) * (n.data?.height || 300), size:
(n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH) *
(n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
})) }))
// Update potential parent if there's at least one intersecting container node // Update potential parent if there's at least one intersecting container node
@@ -1860,12 +1894,12 @@ const WorkflowContent = React.memo(() => {
// Apply appropriate class based on container type // Apply appropriate class based on container type
if ( if (
bestContainerMatch.container.type === 'subflowNode' && bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as any)?.kind === 'loop' (bestContainerMatch.container.data as SubflowNodeData)?.kind === 'loop'
) { ) {
containerElement.classList.add('loop-node-drag-over') containerElement.classList.add('loop-node-drag-over')
} else if ( } else if (
bestContainerMatch.container.type === 'subflowNode' && bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as any)?.kind === 'parallel' (bestContainerMatch.container.data as SubflowNodeData)?.kind === 'parallel'
) { ) {
containerElement.classList.add('parallel-node-drag-over') containerElement.classList.add('parallel-node-drag-over')
} }
@@ -2037,64 +2071,19 @@ const WorkflowContent = React.memo(() => {
y: nodeAbsPosBefore.y - containerAbsPosBefore.y - headerHeight - topPadding, y: nodeAbsPosBefore.y - containerAbsPosBefore.y - headerHeight - topPadding,
} }
// Prepare edges that will be added when moving into the container
const edgesToAdd: any[] = []
// Auto-connect when moving an existing block into a container // Auto-connect when moving an existing block into a container
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled const existingChildBlocks = Object.values(blocks)
// Don't auto-connect annotation blocks (like note blocks) .filter((b) => b.data?.parentId === potentialParentId && b.id !== node.id)
if (isAutoConnectEnabled && !isAnnotationOnlyBlock(node.data?.type)) { .map((b) => ({ id: b.id, type: b.type, position: b.position }))
// Existing children in the target container (excluding the moved node)
const existingChildBlocks = Object.values(blocks).filter(
(b) => b.data?.parentId === potentialParentId && b.id !== node.id
)
if (existingChildBlocks.length > 0) { const autoConnectEdge = tryCreateAutoConnectEdge(relativePositionBefore, node.id, {
// Connect from nearest existing child inside the container blockType: node.data?.type || '',
// Filter out response blocks since they have no outgoing handles targetParentId: potentialParentId,
const closestBlock = existingChildBlocks existingChildBlocks,
.filter((b) => b.type !== 'response') containerId: potentialParentId,
.map((b) => ({ })
block: b,
distance: Math.sqrt(
(b.position.x - relativePositionBefore.x) ** 2 +
(b.position.y - relativePositionBefore.y) ** 2
),
}))
.sort((a, b) => a.distance - b.distance)[0]?.block
if (closestBlock) { const edgesToAdd: Edge[] = autoConnectEdge ? [autoConnectEdge] : []
const sourceHandle = determineSourceHandle({
id: closestBlock.id,
type: closestBlock.type,
})
edgesToAdd.push({
id: crypto.randomUUID(),
source: closestBlock.id,
target: node.id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
} else {
// No children: connect from the container's start handle to the moved node
const containerNode = getNodes().find((n) => n.id === potentialParentId)
const startSourceHandle =
(containerNode?.data as any)?.kind === 'loop'
? 'loop-start-source'
: 'parallel-start-source'
edgesToAdd.push({
id: crypto.randomUUID(),
source: potentialParentId,
target: node.id,
sourceHandle: startSourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
}
// Skip recording these edges separately since they're part of the parent update // Skip recording these edges separately since they're part of the parent update
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } })) window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
@@ -2119,7 +2108,7 @@ const WorkflowContent = React.memo(() => {
updateNodeParent, updateNodeParent,
collaborativeUpdateBlockPosition, collaborativeUpdateBlockPosition,
addEdge, addEdge,
determineSourceHandle, tryCreateAutoConnectEdge,
blocks, blocks,
edgesForDisplay, edgesForDisplay,
removeEdgesForNode, removeEdgesForNode,