diff --git a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx
index 862347d0b8..1d2ccfd868 100644
--- a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx
+++ b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx
@@ -8,6 +8,7 @@ import {
ChevronDown,
Copy,
History,
+ Layers,
Loader2,
Play,
SkipForward,
@@ -698,6 +699,37 @@ export function ControlBar() {
)
+ /**
+ * Render auto-layout button
+ */
+ const renderAutoLayoutButton = () => {
+ const handleAutoLayoutClick = () => {
+ if (isExecuting || isMultiRunning || isDebugging) {
+ return
+ }
+
+ window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
+ }
+
+ return (
+
+
+
+
+ Auto Layout
+
+ )
+ }
+
/**
* Render debug mode controls
*/
@@ -975,6 +1007,7 @@ export function ControlBar() {
{renderHistoryDropdown()}
{renderNotificationsDropdown()}
{renderDuplicateButton()}
+ {renderAutoLayoutButton()}
{renderDebugModeToggle()}
{/* {renderPublishButton()} */}
{renderDeployButton()}
diff --git a/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx
index 0b87808d06..d044dfba7b 100644
--- a/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx
+++ b/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx
@@ -12,8 +12,6 @@ export const WorkflowEdge = ({
data,
style,
}: EdgeProps) => {
- const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left'
-
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
@@ -22,7 +20,7 @@ export const WorkflowEdge = ({
targetY,
targetPosition,
borderRadius: 8,
- offset: isHorizontal ? 30 : 20,
+ offset: 10,
})
// Use the directly provided isSelected flag instead of computing it
diff --git a/apps/sim/app/w/[id]/utils.ts b/apps/sim/app/w/[id]/utils.ts
index d16f092581..3d269af5a1 100644
--- a/apps/sim/app/w/[id]/utils.ts
+++ b/apps/sim/app/w/[id]/utils.ts
@@ -2,13 +2,198 @@ import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('WorkflowUtils')
-// Default dimensions for loop and parallel container nodes
const DEFAULT_CONTAINER_WIDTH = 500
const DEFAULT_CONTAINER_HEIGHT = 300
/**
- * Utility functions for handling node hierarchies and loop operations in the workflow
+ * Check if a block is a container type
*/
+const isContainerType = (blockType: string): boolean => {
+ return (
+ blockType === 'loop' ||
+ blockType === 'parallel' ||
+ blockType === 'loopNode' ||
+ blockType === 'parallelNode'
+ )
+}
+
+/**
+ * Check if a block is a container block
+ */
+const isContainerBlock = (blocks: Record, blockId: string): boolean => {
+ const block = blocks[blockId]
+ return block && isContainerType(block.type)
+}
+
+/**
+ * Get the priority score of a block
+ */
+const getBlockPriorityScore = (
+ blockId: string,
+ orphanedBlocks: Set,
+ disabledBlocks: Set,
+ terminalBlocks: Set
+): number => {
+ if (orphanedBlocks.has(blockId)) return 3
+ if (disabledBlocks.has(blockId)) return 2
+ if (terminalBlocks.has(blockId)) return 1
+ return 0
+}
+
+/**
+ * Get the type of a block
+ */
+const getBlockType = (
+ blockId: string,
+ orphanedBlocks: Set,
+ disabledBlocks: Set,
+ terminalBlocks: Set
+): 'orphaned' | 'disabled' | 'terminal' | 'regular' => {
+ if (orphanedBlocks.has(blockId)) return 'orphaned'
+ if (disabledBlocks.has(blockId)) return 'disabled'
+ if (terminalBlocks.has(blockId)) return 'terminal'
+ return 'regular'
+}
+
+/**
+ * Calculate extra spacing between blocks of different types
+ */
+const calculateExtraSpacing = (
+ currentBlockType: string,
+ nextBlockType: string,
+ baseSpacing: number,
+ multiplier = 0.3
+): number => {
+ return currentBlockType !== nextBlockType ? baseSpacing * multiplier : 0
+}
+
+/**
+ * Calculate the dimensions of a group of blocks
+ */
+const calculateGroupDimensions = (
+ group: string[],
+ orphanedBlocks: Set,
+ disabledBlocks: Set,
+ terminalBlocks: Set,
+ blocks: Record,
+ spacing: number,
+ getDimension: (blocks: Record, blockId: string) => number
+): number => {
+ const sortedGroup = sortBlocksByPriority(group, orphanedBlocks, disabledBlocks, terminalBlocks)
+
+ let totalDimension = 0
+ sortedGroup.forEach((nodeId, index) => {
+ const blockDimension = getDimension(blocks, nodeId)
+ totalDimension += blockDimension
+
+ if (index < sortedGroup.length - 1) {
+ const currentBlockType = getBlockType(nodeId, orphanedBlocks, disabledBlocks, terminalBlocks)
+ const nextBlockType = getBlockType(
+ sortedGroup[index + 1],
+ orphanedBlocks,
+ disabledBlocks,
+ terminalBlocks
+ )
+ const extraSpacing = calculateExtraSpacing(currentBlockType, nextBlockType, spacing)
+ totalDimension += spacing * 0.5 + extraSpacing
+ }
+ })
+
+ return totalDimension
+}
+
+/**
+ * Group nodes by their parent relationships
+ */
+const groupNodesByParents = (
+ nodes: string[],
+ edges: any[],
+ blocks: Record,
+ keyPrefix = ''
+): Map => {
+ const parentGroups = new Map()
+
+ nodes.forEach((nodeId) => {
+ const parents: string[] = []
+ edges.forEach((edge) => {
+ if (edge.target === nodeId && blocks[edge.source]) {
+ parents.push(edge.source)
+ }
+ })
+
+ const parentKey = parents.sort().join(',') || `${keyPrefix}no-parent`
+
+ if (!parentGroups.has(parentKey)) {
+ parentGroups.set(parentKey, [])
+ }
+ parentGroups.get(parentKey)!.push(nodeId)
+ })
+
+ return parentGroups
+}
+
+/**
+ * Sort blocks by priority
+ */
+const sortBlocksByPriority = (
+ blocks: string[],
+ orphanedBlocks: Set,
+ disabledBlocks: Set,
+ terminalBlocks: Set
+): string[] => {
+ return [...blocks].sort((a, b) => {
+ const aScore = getBlockPriorityScore(a, orphanedBlocks, disabledBlocks, terminalBlocks)
+ const bScore = getBlockPriorityScore(b, orphanedBlocks, disabledBlocks, terminalBlocks)
+ if (aScore !== bScore) return aScore - bScore
+ return a.localeCompare(b)
+ })
+}
+
+/**
+ * Get the dimensions of a block
+ */
+const getBlockDimensions = (
+ blocks: Record,
+ blockId: string
+): { width: number; height: number } => {
+ const block = blocks[blockId]
+ if (!block) return { width: 350, height: 150 }
+
+ if (isContainerType(block.type)) {
+ return {
+ width: block.data?.width ? Math.max(block.data.width, 400) : DEFAULT_CONTAINER_WIDTH,
+ height: block.data?.height ? Math.max(block.data.height, 200) : DEFAULT_CONTAINER_HEIGHT,
+ }
+ }
+
+ if (block.type === 'workflowBlock') {
+ const nodeWidth = block.data?.width || block.width
+ const nodeHeight = block.data?.height || block.height
+
+ if (nodeWidth && nodeHeight) {
+ return { width: nodeWidth, height: nodeHeight }
+ }
+ }
+
+ return {
+ width: block.isWide ? 450 : block.data?.width || block.width || 350,
+ height: Math.max(block.height || block.data?.height || 150, 100),
+ }
+}
+
+/**
+ * Get the height of a block
+ */
+const getBlockHeight = (blocks: Record, blockId: string): number => {
+ return getBlockDimensions(blocks, blockId).height
+}
+
+/**
+ * Get the width of a block
+ */
+const getBlockWidth = (blocks: Record, blockId: string): number => {
+ return getBlockDimensions(blocks, blockId).width
+}
/**
* Calculates the depth of a node in the hierarchy tree
@@ -19,8 +204,7 @@ const DEFAULT_CONTAINER_HEIGHT = 300
*/
export const getNodeDepth = (nodeId: string, getNodes: () => any[], maxDepth = 100): number => {
const node = getNodes().find((n) => n.id === nodeId)
- if (!node || !node.parentId) return 0
- if (maxDepth <= 0) return 0
+ if (!node || !node.parentId || maxDepth <= 0) return 0
return 1 + getNodeDepth(node.parentId, getNodes, maxDepth - 1)
}
@@ -48,8 +232,6 @@ export const getNodeAbsolutePosition = (
): { x: number; y: number } => {
const node = getNodes().find((n) => n.id === nodeId)
if (!node) {
- // Handle case where node doesn't exist anymore by returning origin position
- // This helps prevent errors during cleanup operations
logger.warn('Attempted to get position of non-existent node', { nodeId })
return { x: 0, y: 0 }
}
@@ -58,10 +240,8 @@ export const getNodeAbsolutePosition = (
return node.position
}
- // Check if parent exists
const parentNode = getNodes().find((n) => n.id === node.parentId)
if (!parentNode) {
- // Parent reference is invalid, return node's current position
logger.warn('Node references non-existent parent', {
nodeId,
invalidParentId: node.parentId,
@@ -69,12 +249,10 @@ export const getNodeAbsolutePosition = (
return node.position
}
- // Check for circular reference to prevent infinite recursion
const visited = new Set()
let current: any = node
while (current?.parentId) {
if (visited.has(current.parentId)) {
- // Circular reference detected
logger.error('Circular parent reference detected', {
nodeId,
parentChain: Array.from(visited),
@@ -85,10 +263,8 @@ export const getNodeAbsolutePosition = (
current = getNodes().find((n) => n.id === current.parentId)
}
- // Get parent's absolute position
const parentPos = getNodeAbsolutePosition(node.parentId, getNodes)
- // Calculate this node's absolute position
return {
x: parentPos.x + node.position.x,
y: parentPos.y + node.position.y,
@@ -107,13 +283,10 @@ export const calculateRelativePosition = (
newParentId: string,
getNodes: () => any[]
): { x: number; y: number } => {
- // Get absolute position of the node
const nodeAbsPos = getNodeAbsolutePosition(nodeId, getNodes)
- // Get absolute position of the new parent
const parentAbsPos = getNodeAbsolutePosition(newParentId, getNodes)
- // Calculate relative position
return {
x: nodeAbsPos.x - parentAbsPos.x,
y: nodeAbsPos.y - parentAbsPos.y,
@@ -137,7 +310,6 @@ export const updateNodeParent = (
updateParentId: (id: string, parentId: string, extent: 'parent') => void,
resizeLoopNodes: () => void
) => {
- // Skip if no change
const node = getNodes().find((n) => n.id === nodeId)
if (!node) return
@@ -145,22 +317,16 @@ export const updateNodeParent = (
if (newParentId === currentParentId) return
if (newParentId) {
- // Moving to a new parent - calculate relative position
const relativePosition = calculateRelativePosition(nodeId, newParentId, getNodes)
- // Update both position and parent
updateBlockPosition(nodeId, relativePosition)
updateParentId(nodeId, newParentId, 'parent')
} else if (currentParentId) {
- // Removing parent - convert to absolute position
const absolutePosition = getNodeAbsolutePosition(nodeId, getNodes)
- // Update position to absolute coordinates and remove parent
updateBlockPosition(nodeId, absolutePosition)
- // Note: updateParentId function signature needs to handle null case
}
- // Resize affected loops
resizeLoopNodes()
}
@@ -178,9 +344,8 @@ export const isPointInLoopNode = (
loopPosition: { x: number; y: number }
dimensions: { width: number; height: number }
} | null => {
- // Find loops and parallel nodes that contain this position point
const containingNodes = getNodes()
- .filter((n) => n.type === 'loopNode' || n.type === 'parallelNode')
+ .filter((n) => isContainerType(n.type))
.filter((n) => {
const rect = {
left: n.position.x,
@@ -205,7 +370,6 @@ export const isPointInLoopNode = (
},
}))
- // Sort by area (smallest first) in case of nested containers
if (containingNodes.length > 0) {
return containingNodes.sort((a, b) => {
const aArea = a.dimensions.width * a.dimensions.height
@@ -221,75 +385,29 @@ export const isPointInLoopNode = (
* Calculates appropriate dimensions for a loop or parallel node based on its children
* @param nodeId ID of the container node
* @param getNodes Function to retrieve all nodes from ReactFlow
+ * @param blocks Block states from workflow store
* @returns Calculated width and height for the container
*/
export const calculateLoopDimensions = (
nodeId: string,
- getNodes: () => any[]
+ getNodes: () => any[],
+ blocks: Record
): { width: number; height: number } => {
- // Default minimum dimensions
const minWidth = DEFAULT_CONTAINER_WIDTH
const minHeight = DEFAULT_CONTAINER_HEIGHT
- // Get all child nodes of this container
const childNodes = getNodes().filter((node) => node.parentId === nodeId)
-
if (childNodes.length === 0) {
return { width: minWidth, height: minHeight }
}
- // Calculate the bounding box that contains all children
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
childNodes.forEach((node) => {
- // Get accurate node dimensions based on node type
- let nodeWidth
- let nodeHeight
-
- if (node.type === 'loopNode' || node.type === 'parallelNode') {
- // For nested containers, use their actual dimensions
- nodeWidth = node.data?.width || DEFAULT_CONTAINER_WIDTH
- nodeHeight = node.data?.height || DEFAULT_CONTAINER_HEIGHT
- } else if (node.type === 'workflowBlock') {
- // Use actual dynamic dimensions from the node data if available
- // Fall back to block type defaults only if no dynamic dimensions exist
- nodeWidth = node.data?.width || node.width
- nodeHeight = node.data?.height || node.height
-
- // If still no dimensions, use block type defaults as last resort
- if (!nodeWidth || !nodeHeight) {
- const blockType = node.data?.type
-
- switch (blockType) {
- case 'agent':
- case 'api':
- // Tall blocks
- nodeWidth = nodeWidth || 350
- nodeHeight = nodeHeight || 650
- break
- case 'condition':
- case 'function':
- nodeWidth = nodeWidth || 250
- nodeHeight = nodeHeight || 200
- break
- case 'router':
- nodeWidth = nodeWidth || 250
- nodeHeight = nodeHeight || 350
- break
- default:
- // Default dimensions for other block types
- nodeWidth = nodeWidth || 200
- nodeHeight = nodeHeight || 200
- }
- }
- } else {
- // For any other node types, try to get actual dimensions first
- nodeWidth = node.data?.width || node.width || 200
- nodeHeight = node.data?.height || node.height || 200
- }
+ const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(blocks, node.id)
minX = Math.min(minX, node.position.x + nodeWidth)
minY = Math.min(minY, node.position.y + nodeHeight)
@@ -297,21 +415,12 @@ export const calculateLoopDimensions = (
maxY = Math.max(maxY, node.position.y + nodeHeight + 50)
})
- // Add buffer padding to all sides (20px buffer before edges)
- // Add extra padding for nested containers to prevent tight boundaries
- const hasNestedContainers = childNodes.some(
- (node) => node.type === 'loopNode' || node.type === 'parallelNode'
- )
+ const hasNestedContainers = childNodes.some((node) => isContainerType(node.type))
- // More reasonable padding values, especially for nested containers
- // Reduce the excessive padding that was causing parent containers to be too large
- const sidePadding = hasNestedContainers ? 150 : 120 // Reduced padding for containers containing other containers
+ const sidePadding = hasNestedContainers ? 150 : 120
- // Add extra padding to the right side to prevent sidebar from covering the right handle
const extraPadding = 50
- // Ensure the width and height are never less than the minimums
- // Apply padding to all sides (left/right and top/bottom) with extra right padding
const width = Math.max(minWidth, maxX + sidePadding + extraPadding)
const height = Math.max(minHeight, maxY + sidePadding)
@@ -322,27 +431,703 @@ export const calculateLoopDimensions = (
* Resizes all loop and parallel nodes based on their children
* @param getNodes Function to retrieve all nodes from ReactFlow
* @param updateNodeDimensions Function to update the dimensions of a node
+ * @param blocks Block states from workflow store
*/
export const resizeLoopNodes = (
getNodes: () => any[],
- updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void
+ updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void,
+ blocks: Record
) => {
- // Find all container nodes and sort by hierarchy depth (parents first)
const containerNodes = getNodes()
- .filter((node) => node.type === 'loopNode' || node.type === 'parallelNode')
+ .filter((node) => isContainerType(node.type))
.map((node) => ({
...node,
depth: getNodeDepth(node.id, getNodes),
}))
.sort((a, b) => a.depth - b.depth)
- // Resize each container node based on its children
containerNodes.forEach((node) => {
- const dimensions = calculateLoopDimensions(node.id, getNodes)
+ const dimensions = calculateLoopDimensions(node.id, getNodes, blocks)
- // Only update if dimensions have changed (to avoid unnecessary updates)
if (dimensions.width !== node.data?.width || dimensions.height !== node.data?.height) {
updateNodeDimensions(node.id, dimensions)
}
})
}
+
+export interface LayoutOptions {
+ horizontalSpacing?: number
+ verticalSpacing?: number
+ startX?: number
+ startY?: number
+ alignByLayer?: boolean
+ handleOrientation?: 'auto' | 'horizontal' | 'vertical'
+}
+
+/**
+ * Detects the predominant handle orientation in the workflow
+ * @param blocks Block states from workflow store
+ * @returns 'horizontal' if most blocks use horizontal handles, 'vertical' otherwise
+ */
+export const detectHandleOrientation = (blocks: Record): 'horizontal' | 'vertical' => {
+ const topLevelBlocks = Object.values(blocks).filter((block) => !block.data?.parentId)
+
+ if (topLevelBlocks.length === 0) {
+ return 'horizontal'
+ }
+
+ let horizontalCount = 0
+ let verticalCount = 0
+
+ topLevelBlocks.forEach((block) => {
+ if (block.horizontalHandles === true) {
+ horizontalCount++
+ } else if (block.horizontalHandles === false) {
+ verticalCount++
+ } else {
+ horizontalCount++
+ }
+ })
+
+ return horizontalCount >= verticalCount ? 'horizontal' : 'vertical'
+}
+
+/**
+ * Analyzes the workflow graph using topological sort to determine execution layers
+ * and properly handle parallel execution paths, disabled blocks, and terminal blocks
+ * @param blocks Block states from workflow store
+ * @param edges Edge connections from workflow store
+ * @returns Map of block IDs to their layer numbers and additional graph data
+ */
+export const analyzeWorkflowGraph = (
+ blocks: Record,
+ edges: any[]
+): {
+ layers: Map
+ parallelGroups: Map
+ maxLayer: number
+ disabledBlocks: Set
+ terminalBlocks: Set
+ orphanedBlocks: Set
+} => {
+ const blockLayers = new Map()
+ const inDegree = new Map()
+ const adjacencyList = new Map()
+ const disabledBlocks = new Set()
+ const terminalBlocks = new Set()
+ const orphanedBlocks = new Set()
+
+ Object.entries(blocks).forEach(([blockId, block]) => {
+ inDegree.set(blockId, 0)
+ adjacencyList.set(blockId, [])
+
+ if (block.enabled === false) {
+ disabledBlocks.add(blockId)
+ }
+ })
+
+ edges.forEach((edge) => {
+ if (edge.source && edge.target && blocks[edge.source] && blocks[edge.target]) {
+ const neighbors = adjacencyList.get(edge.source) || []
+ neighbors.push(edge.target)
+ adjacencyList.set(edge.source, neighbors)
+ inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1)
+ }
+ })
+
+ Object.keys(blocks).forEach((blockId) => {
+ const neighbors = adjacencyList.get(blockId) || []
+ const block = blocks[blockId]
+
+ if (neighbors.length === 0 && !isContainerType(block.type)) {
+ terminalBlocks.add(blockId)
+ }
+ })
+
+ Object.keys(blocks).forEach((blockId) => {
+ const inDegreeValue = inDegree.get(blockId) || 0
+ const outDegreeValue = (adjacencyList.get(blockId) || []).length
+ const block = blocks[blockId]
+
+ if (inDegreeValue === 0 && outDegreeValue === 0 && block.type !== 'starter') {
+ orphanedBlocks.add(blockId)
+ }
+ })
+
+ const queue: string[] = []
+ inDegree.forEach((degree, blockId) => {
+ if (degree === 0 || blocks[blockId].type === 'starter') {
+ queue.push(blockId)
+ blockLayers.set(blockId, 0)
+ }
+ })
+
+ const visited = new Set()
+ let maxLayer = 0
+
+ while (queue.length > 0) {
+ const currentId = queue.shift()!
+ visited.add(currentId)
+
+ const currentLayer = blockLayers.get(currentId) || 0
+ maxLayer = Math.max(maxLayer, currentLayer)
+
+ const neighbors = adjacencyList.get(currentId) || []
+ neighbors.forEach((neighborId) => {
+ const existingLayer = blockLayers.get(neighborId) || 0
+ blockLayers.set(neighborId, Math.max(existingLayer, currentLayer + 1))
+
+ const newInDegree = (inDegree.get(neighborId) || 1) - 1
+ inDegree.set(neighborId, newInDegree)
+
+ if (newInDegree === 0 && !visited.has(neighborId)) {
+ queue.push(neighborId)
+ }
+ })
+ }
+
+ Object.keys(blocks).forEach((blockId) => {
+ if (!blockLayers.has(blockId)) {
+ if (orphanedBlocks.has(blockId)) {
+ blockLayers.set(blockId, maxLayer + 2)
+ } else {
+ blockLayers.set(blockId, 0)
+ }
+ }
+ })
+
+ blockLayers.forEach((layer) => {
+ maxLayer = Math.max(maxLayer, layer)
+ })
+
+ const parallelGroups = new Map()
+ const layerNodes = new Map()
+
+ blockLayers.forEach((layer, blockId) => {
+ if (!layerNodes.has(layer)) {
+ layerNodes.set(layer, [])
+ }
+ layerNodes.get(layer)!.push(blockId)
+ })
+
+ layerNodes.forEach((nodes, layer) => {
+ if (nodes.length === 1) {
+ parallelGroups.set(layer, [[nodes[0]]])
+ } else {
+ const disabledNodesInLayer = nodes.filter((nodeId) => disabledBlocks.has(nodeId))
+ const terminalNodesInLayer = nodes.filter((nodeId) => terminalBlocks.has(nodeId))
+ const orphanedNodesInLayer = nodes.filter((nodeId) => orphanedBlocks.has(nodeId))
+ const regularNodes = nodes.filter(
+ (nodeId) =>
+ !disabledBlocks.has(nodeId) && !terminalBlocks.has(nodeId) && !orphanedBlocks.has(nodeId)
+ )
+
+ const regularParentGroups = groupNodesByParents(regularNodes, edges, blocks)
+ const groups = Array.from(regularParentGroups.values())
+
+ if (disabledNodesInLayer.length > 0) {
+ const disabledParentGroups = groupNodesByParents(
+ disabledNodesInLayer,
+ edges,
+ blocks,
+ 'disabled-'
+ )
+ groups.push(...Array.from(disabledParentGroups.values()))
+ }
+
+ if (terminalNodesInLayer.length > 0) {
+ groups.push(terminalNodesInLayer)
+ }
+
+ if (orphanedNodesInLayer.length > 0) {
+ groups.push(orphanedNodesInLayer)
+ }
+
+ parallelGroups.set(layer, groups)
+ }
+ })
+
+ return {
+ layers: blockLayers,
+ parallelGroups,
+ maxLayer,
+ disabledBlocks,
+ terminalBlocks,
+ orphanedBlocks,
+ }
+}
+
+/**
+ * Calculates auto-layout positions for workflow blocks with improved spacing and alignment
+ * Enhanced to handle both horizontal and vertical handle orientations
+ * @param blocks Block states from workflow store
+ * @param edges Edge connections from workflow store
+ * @param options Layout configuration options
+ * @returns Map of block IDs to their new positions
+ */
+export const calculateAutoLayout = (
+ blocks: Record,
+ edges: any[],
+ options: LayoutOptions = {}
+): Map => {
+ const {
+ horizontalSpacing = 600,
+ verticalSpacing = 200,
+ startX = 300,
+ startY = 300,
+ alignByLayer = true,
+ handleOrientation = 'auto',
+ } = options
+
+ const newPositions = new Map()
+
+ const topLevelBlocks = Object.fromEntries(
+ Object.entries(blocks).filter(([_, block]) => !block.data?.parentId)
+ )
+
+ if (Object.keys(topLevelBlocks).length === 0) {
+ return newPositions
+ }
+
+ let actualOrientation: 'horizontal' | 'vertical'
+ if (handleOrientation === 'auto') {
+ actualOrientation = detectHandleOrientation(blocks)
+ } else {
+ actualOrientation = handleOrientation
+ }
+
+ if (alignByLayer) {
+ const { parallelGroups, maxLayer, disabledBlocks, terminalBlocks, orphanedBlocks } =
+ analyzeWorkflowGraph(topLevelBlocks, edges)
+
+ if (actualOrientation === 'horizontal') {
+ const calculateLayerSpacing = (currentLayer: number, nextLayer: number): number => {
+ const currentLayerGroups = parallelGroups.get(currentLayer) || []
+ const nextLayerGroups = parallelGroups.get(nextLayer) || []
+
+ let maxCurrentWidth = 0
+ currentLayerGroups.forEach((group: string[]) => {
+ group.forEach((nodeId: string) => {
+ maxCurrentWidth = Math.max(maxCurrentWidth, getBlockWidth(blocks, nodeId))
+ })
+ })
+
+ let maxNextWidth = 0
+ nextLayerGroups.forEach((group: string[]) => {
+ group.forEach((nodeId: string) => {
+ maxNextWidth = Math.max(maxNextWidth, getBlockWidth(blocks, nodeId))
+ })
+ })
+
+ const baseSpacing = horizontalSpacing
+ const widthAdjustment = Math.max(maxCurrentWidth, maxNextWidth) - 350 // 350 is standard width
+ const connectionTagSpace = 50 // Reduced from 100 - Extra space for connection tags
+
+ const isOrphanedLayer =
+ currentLayer > maxLayer - 2 &&
+ (currentLayerGroups.some((group) => group.some((nodeId) => orphanedBlocks.has(nodeId))) ||
+ nextLayerGroups.some((group) => group.some((nodeId) => orphanedBlocks.has(nodeId))))
+ const orphanedSpacing = isOrphanedLayer ? 100 : 0
+
+ return baseSpacing + widthAdjustment + connectionTagSpace + orphanedSpacing
+ }
+
+ let currentLayerX = startX
+
+ for (let layer = 0; layer <= maxLayer; layer++) {
+ const groups = parallelGroups.get(layer) || []
+
+ let totalHeight = 0
+ const groupHeights: number[] = []
+
+ groups.forEach((group) => {
+ const groupHeight = calculateGroupDimensions(
+ group,
+ orphanedBlocks,
+ disabledBlocks,
+ terminalBlocks,
+ blocks,
+ verticalSpacing,
+ getBlockHeight
+ )
+ groupHeights.push(groupHeight)
+ totalHeight += groupHeight
+ })
+
+ if (groups.length > 1) {
+ totalHeight += (groups.length - 1) * verticalSpacing
+ }
+
+ let currentY = startY - totalHeight / 2
+
+ groups.forEach((group, groupIndex) => {
+ const sortedGroup = sortBlocksByPriority(
+ group,
+ orphanedBlocks,
+ disabledBlocks,
+ terminalBlocks
+ )
+
+ sortedGroup.forEach((nodeId, nodeIndex) => {
+ const blockHeight = getBlockHeight(blocks, nodeId)
+
+ let positionY = currentY
+ if (isContainerBlock(blocks, nodeId)) {
+ positionY = currentY
+ }
+
+ newPositions.set(nodeId, {
+ x: currentLayerX,
+ y: positionY,
+ })
+
+ currentY += blockHeight
+
+ if (nodeIndex < sortedGroup.length - 1) {
+ const currentBlockType = getBlockType(
+ nodeId,
+ orphanedBlocks,
+ disabledBlocks,
+ terminalBlocks
+ )
+ const nextBlockType = getBlockType(
+ sortedGroup[nodeIndex + 1],
+ orphanedBlocks,
+ disabledBlocks,
+ terminalBlocks
+ )
+
+ const extraSpacing = calculateExtraSpacing(
+ currentBlockType,
+ nextBlockType,
+ verticalSpacing
+ )
+ currentY += verticalSpacing * 0.5 + extraSpacing
+ }
+ })
+
+ if (groupIndex < groups.length - 1) {
+ currentY += verticalSpacing
+ }
+ })
+
+ if (layer < maxLayer) {
+ const dynamicSpacing = calculateLayerSpacing(layer, layer + 1)
+ currentLayerX += dynamicSpacing
+ }
+ }
+ } else {
+ const calculateLayerSpacing = (currentLayer: number, nextLayer: number): number => {
+ const currentLayerGroups = parallelGroups.get(currentLayer) || []
+ const nextLayerGroups = parallelGroups.get(nextLayer) || []
+
+ let maxCurrentHeight = 0
+ currentLayerGroups.forEach((group: string[]) => {
+ group.forEach((nodeId: string) => {
+ maxCurrentHeight = Math.max(maxCurrentHeight, getBlockHeight(blocks, nodeId))
+ })
+ })
+
+ let maxNextHeight = 0
+ nextLayerGroups.forEach((group: string[]) => {
+ group.forEach((nodeId: string) => {
+ maxNextHeight = Math.max(maxNextHeight, getBlockHeight(blocks, nodeId))
+ })
+ })
+
+ const baseSpacing = verticalSpacing
+ const heightAdjustment = Math.max(maxCurrentHeight, maxNextHeight) - 150 // 150 is standard height
+ const connectionTagSpace = 25
+
+ const isOrphanedLayer =
+ currentLayer > maxLayer - 2 &&
+ (currentLayerGroups.some((group) => group.some((nodeId) => orphanedBlocks.has(nodeId))) ||
+ nextLayerGroups.some((group) => group.some((nodeId) => orphanedBlocks.has(nodeId))))
+ const orphanedSpacing = isOrphanedLayer ? 75 : 0
+
+ return baseSpacing + heightAdjustment + connectionTagSpace + orphanedSpacing
+ }
+
+ let currentLayerY = startY
+
+ for (let layer = 0; layer <= maxLayer; layer++) {
+ const groups = parallelGroups.get(layer) || []
+
+ let totalWidth = 0
+ const groupWidths: number[] = []
+
+ groups.forEach((group) => {
+ const groupWidth = calculateGroupDimensions(
+ group,
+ orphanedBlocks,
+ disabledBlocks,
+ terminalBlocks,
+ blocks,
+ horizontalSpacing,
+ getBlockWidth
+ )
+ groupWidths.push(groupWidth)
+ totalWidth += groupWidth
+ })
+
+ if (groups.length > 1) {
+ totalWidth += (groups.length - 1) * horizontalSpacing
+ }
+
+ let currentX = startX - totalWidth / 2
+
+ groups.forEach((group, groupIndex) => {
+ const sortedGroup = sortBlocksByPriority(
+ group,
+ orphanedBlocks,
+ disabledBlocks,
+ terminalBlocks
+ )
+
+ sortedGroup.forEach((nodeId, nodeIndex) => {
+ const blockWidth = getBlockWidth(blocks, nodeId)
+
+ let positionX = currentX
+ if (isContainerBlock(blocks, nodeId)) {
+ positionX = currentX
+ }
+
+ newPositions.set(nodeId, {
+ x: positionX,
+ y: currentLayerY,
+ })
+
+ currentX += blockWidth
+
+ if (nodeIndex < sortedGroup.length - 1) {
+ const currentBlockType = getBlockType(
+ nodeId,
+ orphanedBlocks,
+ disabledBlocks,
+ terminalBlocks
+ )
+ const nextBlockType = getBlockType(
+ sortedGroup[nodeIndex + 1],
+ orphanedBlocks,
+ disabledBlocks,
+ terminalBlocks
+ )
+
+ const extraSpacing = calculateExtraSpacing(
+ currentBlockType,
+ nextBlockType,
+ horizontalSpacing
+ )
+ currentX += horizontalSpacing * 0.5 + extraSpacing
+ }
+ })
+
+ if (groupIndex < groups.length - 1) {
+ currentX += horizontalSpacing
+ }
+ })
+
+ if (layer < maxLayer) {
+ const dynamicSpacing = calculateLayerSpacing(layer, layer + 1)
+ currentLayerY += dynamicSpacing
+ }
+ }
+ }
+ } else {
+ const blockIds = Object.keys(topLevelBlocks)
+
+ if (actualOrientation === 'horizontal') {
+ let currentX = startX
+
+ blockIds.forEach((blockId, index) => {
+ newPositions.set(blockId, { x: currentX, y: startY })
+
+ if (index < blockIds.length - 1) {
+ const blockWidth = getBlockWidth(blocks, blockId)
+ const nextBlockWidth = getBlockWidth(blocks, blockIds[index + 1])
+ const spacing = horizontalSpacing + Math.max(blockWidth, nextBlockWidth) - 350
+ currentX += spacing
+ }
+ })
+ } else {
+ let currentY = startY
+
+ blockIds.forEach((blockId, index) => {
+ newPositions.set(blockId, { x: startX, y: currentY })
+
+ if (index < blockIds.length - 1) {
+ const blockHeight = getBlockHeight(blocks, blockId)
+ const nextBlockHeight = getBlockHeight(blocks, blockIds[index + 1])
+ const spacing = verticalSpacing + Math.max(blockHeight, nextBlockHeight) - 150
+ currentY += spacing
+ }
+ })
+ }
+ }
+
+ return newPositions
+}
+
+/**
+ * Enhanced auto-layout function with smooth animations
+ * @param blocks Block states from workflow store
+ * @param edges Edge connections from workflow store
+ * @param updateBlockPosition Function to update block positions
+ * @param fitView Function to fit the view
+ * @param resizeLoopNodes Function to resize loop nodes
+ * @param options Layout configuration options
+ */
+export const applyAutoLayoutSmooth = (
+ blocks: Record,
+ edges: any[],
+ updateBlockPosition: (id: string, position: { x: number; y: number }) => void,
+ fitView: (options?: { padding?: number; duration?: number }) => void,
+ resizeLoopNodes: () => void,
+ options: LayoutOptions & {
+ animationDuration?: number
+ isSidebarCollapsed?: boolean
+ } = {}
+): void => {
+ const { animationDuration = 500, isSidebarCollapsed = false, ...layoutOptions } = options
+
+ if (!layoutOptions.handleOrientation || layoutOptions.handleOrientation === 'auto') {
+ layoutOptions.handleOrientation = detectHandleOrientation(blocks)
+ }
+
+ const topLevelPositions = calculateAutoLayout(blocks, edges, layoutOptions)
+
+ const childPositions = new Map()
+
+ const containerBlocks = Object.entries(blocks).filter(
+ ([_, block]) => isContainerType(block.type) && !block.data?.parentId
+ )
+
+ containerBlocks.forEach(([containerId]) => {
+ const childBlocks = Object.fromEntries(
+ Object.entries(blocks).filter(([_, block]) => block.data?.parentId === containerId)
+ )
+
+ if (Object.keys(childBlocks).length === 0) return
+
+ const childEdges = edges.filter((edge) => childBlocks[edge.source] && childBlocks[edge.target])
+
+ const childLayoutOptions: LayoutOptions = {
+ horizontalSpacing: Math.min(300, layoutOptions.horizontalSpacing || 300),
+ verticalSpacing: Math.min(150, layoutOptions.verticalSpacing || 150),
+ startX: 50,
+ startY: 80,
+ alignByLayer: true,
+ handleOrientation: layoutOptions.handleOrientation,
+ }
+
+ const childPositionsForContainer = calculateAutoLayout(
+ childBlocks,
+ childEdges,
+ childLayoutOptions
+ )
+
+ childPositionsForContainer.forEach((position, blockId) => {
+ childPositions.set(blockId, position)
+ })
+ })
+
+ const allPositions = new Map([...topLevelPositions, ...childPositions])
+
+ if (allPositions.size === 0) return
+
+ const currentPositions = new Map()
+ allPositions.forEach((_, blockId) => {
+ const block = blocks[blockId]
+ if (block) {
+ currentPositions.set(blockId, { x: block.position.x, y: block.position.y })
+ }
+ })
+
+ const startTime = Date.now()
+ const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3
+
+ const animate = async () => {
+ const elapsed = Date.now() - startTime
+ const progress = Math.min(elapsed / animationDuration, 1)
+ const easedProgress = easeOutCubic(progress)
+
+ allPositions.forEach((targetPosition, blockId) => {
+ const currentPosition = currentPositions.get(blockId)
+ if (!currentPosition) return
+
+ const newPosition = {
+ x: currentPosition.x + (targetPosition.x - currentPosition.x) * easedProgress,
+ y: currentPosition.y + (targetPosition.y - currentPosition.y) * easedProgress,
+ }
+
+ updateBlockPosition(blockId, newPosition)
+ })
+
+ if (progress < 1) {
+ requestAnimationFrame(animate)
+ } else {
+ await resizeLoopNodes()
+
+ const padding = isSidebarCollapsed ? 0.35 : 0.55
+ await fitView({
+ padding,
+ duration: 400,
+ })
+ }
+ }
+
+ animate()
+}
+
+/**
+ * Original auto-layout function (for backward compatibility)
+ * @param blocks Block states from workflow store
+ * @param edges Edge connections from workflow store
+ * @param updateBlockPosition Function to update block positions
+ * @param options Layout configuration options
+ */
+export const applyAutoLayout = (
+ blocks: Record,
+ edges: any[],
+ updateBlockPosition: (id: string, position: { x: number; y: number }) => void,
+ options: LayoutOptions = {}
+): void => {
+ if (!options.handleOrientation || options.handleOrientation === 'auto') {
+ options.handleOrientation = detectHandleOrientation(blocks)
+ }
+
+ const topLevelPositions = calculateAutoLayout(blocks, edges, options)
+
+ topLevelPositions.forEach((position, blockId) => {
+ updateBlockPosition(blockId, position)
+ })
+
+ const containerBlocks = Object.entries(blocks).filter(
+ ([_, block]) => isContainerType(block.type) && !block.data?.parentId
+ )
+
+ containerBlocks.forEach(([containerId]) => {
+ const childBlocks = Object.fromEntries(
+ Object.entries(blocks).filter(([_, block]) => block.data?.parentId === containerId)
+ )
+
+ if (Object.keys(childBlocks).length === 0) return
+
+ const childEdges = edges.filter((edge) => childBlocks[edge.source] && childBlocks[edge.target])
+
+ const childLayoutOptions: LayoutOptions = {
+ horizontalSpacing: Math.min(300, options.horizontalSpacing || 300),
+ verticalSpacing: Math.min(150, options.verticalSpacing || 150),
+ startX: 50,
+ startY: 80,
+ alignByLayer: true,
+ handleOrientation: options.handleOrientation,
+ }
+
+ const childPositions = calculateAutoLayout(childBlocks, childEdges, childLayoutOptions)
+
+ childPositions.forEach((position, blockId) => {
+ updateBlockPosition(blockId, position)
+ })
+ })
+}
diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx
index 223c234e48..a0601613de 100644
--- a/apps/sim/app/w/[id]/workflow.tsx
+++ b/apps/sim/app/w/[id]/workflow.tsx
@@ -34,8 +34,8 @@ import { Toolbar } from './components/toolbar/toolbar'
import { WorkflowBlock } from './components/workflow-block/workflow-block'
import { WorkflowEdge } from './components/workflow-edge/workflow-edge'
import {
- calculateLoopDimensions,
- calculateRelativePosition,
+ applyAutoLayoutSmooth,
+ detectHandleOrientation,
getNodeAbsolutePosition,
getNodeDepth,
getNodeHierarchy,
@@ -73,11 +73,10 @@ function WorkflowContent() {
// Hooks
const params = useParams()
const router = useRouter()
- const { project, getNodes } = useReactFlow()
+ const { project, getNodes, fitView } = useReactFlow()
// Store access
const { workflows, setActiveWorkflow, createWorkflow } = useWorkflowRegistry()
- //Removed loops from the store
const {
blocks,
edges,
@@ -98,6 +97,26 @@ function WorkflowContent() {
const { isDebugModeEnabled } = useGeneralStore()
const [dragStartParentId, setDragStartParentId] = useState(null)
+ // Helper function to update a node's parent with proper position calculation
+ const updateNodeParent = useCallback(
+ (nodeId: string, newParentId: string | null) => {
+ return updateNodeParentUtil(
+ nodeId,
+ newParentId,
+ getNodes,
+ updateBlockPosition,
+ updateParentId,
+ () => resizeLoopNodes(getNodes, updateNodeDimensions, blocks)
+ )
+ },
+ [getNodes, updateBlockPosition, updateParentId, updateNodeDimensions, blocks]
+ )
+
+ // Function to resize all loop nodes with improved hierarchy handling
+ const resizeLoopNodesWrapper = useCallback(() => {
+ return resizeLoopNodes(getNodes, updateNodeDimensions, blocks)
+ }, [getNodes, updateNodeDimensions, blocks])
+
// Wrapper functions that use the utilities but provide the getNodes function
const getNodeDepthWrapper = useCallback(
(nodeId: string): number => {
@@ -120,28 +139,6 @@ function WorkflowContent() {
[getNodes]
)
- const calculateRelativePositionWrapper = useCallback(
- (nodeId: string, newParentId: string): { x: number; y: number } => {
- return calculateRelativePosition(nodeId, newParentId, getNodes)
- },
- [getNodes]
- )
-
- // Helper function to update a node's parent with proper position calculation
- const updateNodeParent = useCallback(
- (nodeId: string, newParentId: string | null) => {
- return updateNodeParentUtil(
- nodeId,
- newParentId,
- getNodes,
- updateBlockPosition,
- updateParentId,
- resizeLoopNodesWrapper
- )
- },
- [getNodes, updateBlockPosition, updateParentId]
- )
-
const isPointInLoopNodeWrapper = useCallback(
(position: { x: number; y: number }) => {
return isPointInLoopNode(position, getNodes)
@@ -149,20 +146,95 @@ function WorkflowContent() {
[getNodes]
)
- const calculateLoopDimensionsWrapper = useCallback(
- (loopId: string): { width: number; height: number } => {
- return calculateLoopDimensions(loopId, getNodes)
- },
- [getNodes]
- )
+ // Auto-layout handler
+ const handleAutoLayout = useCallback(() => {
+ if (Object.keys(blocks).length === 0) return
- // Function to resize all loop nodes with improved hierarchy handling
- const resizeLoopNodesWrapper = useCallback(() => {
- return resizeLoopNodes(getNodes, updateNodeDimensions)
- }, [getNodes, updateNodeDimensions])
+ // Detect the predominant handle orientation in the workflow
+ const detectedOrientation = detectHandleOrientation(blocks)
- // Use direct resizing function instead of debounced version for immediate updates
- const debouncedResizeLoopNodes = resizeLoopNodesWrapper
+ // Optimize spacing based on handle orientation
+ const orientationConfig =
+ detectedOrientation === 'vertical'
+ ? {
+ // Vertical handles: optimize for top-to-bottom flow
+ horizontalSpacing: 400,
+ verticalSpacing: 150,
+ startX: 200,
+ startY: 200,
+ }
+ : {
+ // Horizontal handles: optimize for left-to-right flow
+ horizontalSpacing: 300,
+ verticalSpacing: 200,
+ startX: 150,
+ startY: 300,
+ }
+
+ applyAutoLayoutSmooth(blocks, edges, updateBlockPosition, fitView, resizeLoopNodesWrapper, {
+ ...orientationConfig,
+ alignByLayer: true,
+ animationDuration: 500, // Smooth 500ms animation
+ isSidebarCollapsed,
+ handleOrientation: detectedOrientation, // Explicitly set the detected orientation
+ })
+
+ const orientationMessage =
+ detectedOrientation === 'vertical'
+ ? 'Auto-layout applied with vertical flow (top-to-bottom)'
+ : 'Auto-layout applied with horizontal flow (left-to-right)'
+
+ logger.info(orientationMessage, {
+ orientation: detectedOrientation,
+ blockCount: Object.keys(blocks).length,
+ })
+ }, [blocks, edges, updateBlockPosition, fitView, isSidebarCollapsed, resizeLoopNodesWrapper])
+
+ const debouncedAutoLayout = useCallback(() => {
+ const debounceTimer = setTimeout(() => {
+ handleAutoLayout()
+ }, 250)
+
+ return () => clearTimeout(debounceTimer)
+ }, [handleAutoLayout])
+
+ useEffect(() => {
+ let cleanup: (() => void) | null = null
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.shiftKey && event.key === 'L' && !event.ctrlKey && !event.metaKey) {
+ event.preventDefault()
+
+ if (cleanup) cleanup()
+
+ cleanup = debouncedAutoLayout()
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ if (cleanup) cleanup()
+ }
+ }, [debouncedAutoLayout])
+
+ useEffect(() => {
+ let cleanup: (() => void) | null = null
+
+ const handleAutoLayoutEvent = () => {
+ if (cleanup) cleanup()
+
+ cleanup = debouncedAutoLayout()
+ }
+
+ window.addEventListener('trigger-auto-layout', handleAutoLayoutEvent)
+
+ return () => {
+ window.removeEventListener('trigger-auto-layout', handleAutoLayoutEvent)
+ if (cleanup) cleanup()
+ }
+ }, [debouncedAutoLayout])
// Initialize workflow
useEffect(() => {
@@ -434,7 +506,7 @@ function WorkflowContent() {
}
// Resize the parent container to fit the new child container
- debouncedResizeLoopNodes()
+ resizeLoopNodesWrapper()
} else {
// Add the container node directly to canvas with default dimensions
addBlock(id, data.type, name, position, {
@@ -495,7 +567,7 @@ function WorkflowContent() {
// Resize the container node to fit the new block
// Immediate resize without delay
- debouncedResizeLoopNodes()
+ resizeLoopNodesWrapper()
// Auto-connect logic for blocks inside containers
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
@@ -802,11 +874,11 @@ function WorkflowContent() {
if (nodes.length === 0) return
// Resize all loops to fit their children
- debouncedResizeLoopNodes()
+ resizeLoopNodesWrapper()
// No need for cleanup with direct function
return () => {}
- }, [nodes, debouncedResizeLoopNodes])
+ }, [nodes, resizeLoopNodesWrapper])
// Special effect to handle cleanup after node deletion
useEffect(() => {