diff --git a/apps/sim/lib/workflows/autolayout/core.test.ts b/apps/sim/lib/workflows/autolayout/core.test.ts new file mode 100644 index 00000000000..3ecf5bba95d --- /dev/null +++ b/apps/sim/lib/workflows/autolayout/core.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { layoutBlocksCore } from '@/lib/workflows/autolayout/core' +import type { Edge } from '@/lib/workflows/autolayout/types' +import type { BlockState } from '@/stores/workflows/workflow/types' + +vi.mock('@/blocks', () => ({ + getBlock: () => null, +})) + +function createBlock(id: string): BlockState { + return { + id, + type: 'agent', + name: id, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + height: 120, + layout: { measuredWidth: 250, measuredHeight: 120 }, + } as BlockState +} + +describe('layoutBlocksCore', () => { + it('keeps each branch in a stable row regardless of block insertion order', () => { + // Two parallel chains from one source. Insertion order is interleaved so the + // per-layer order flips: layer1 [a1,b1], layer2 [b2,a2], layer3 [a3,b3]. + // Row assignment must come from the resolved predecessor position, not from + // per-layer insertion-order tie-breaks. + const blocks: Record = {} + for (const id of ['s', 'a1', 'b1', 'b2', 'a2', 'a3', 'b3']) { + blocks[id] = createBlock(id) + } + const edges: Edge[] = [ + { id: 'e1', source: 's', target: 'a1' }, + { id: 'e2', source: 's', target: 'b1' }, + { id: 'e3', source: 'a1', target: 'a2' }, + { id: 'e4', source: 'b1', target: 'b2' }, + { id: 'e5', source: 'a2', target: 'a3' }, + { id: 'e6', source: 'b2', target: 'b3' }, + ] + + const { nodes } = layoutBlocksCore(blocks, edges, { isContainer: false }) + const y = (id: string) => nodes.get(id)!.position.y + + const aAboveInLayer1 = y('a1') < y('b1') + expect(y('a2') < y('b2')).toBe(aAboveInLayer1) + expect(y('a3') < y('b3')).toBe(aAboveInLayer1) + }) + + it('leaves no vertical overlaps within a layer', () => { + const blocks: Record = {} + for (const id of ['s', 'a1', 'b1', 'c1']) { + blocks[id] = createBlock(id) + } + const edges: Edge[] = [ + { id: 'e1', source: 's', target: 'a1' }, + { id: 'e2', source: 's', target: 'b1' }, + { id: 'e3', source: 's', target: 'c1' }, + ] + + const { nodes } = layoutBlocksCore(blocks, edges, { isContainer: false }) + const layer1 = ['a1', 'b1', 'c1'] + .map((id) => nodes.get(id)!) + .sort((a, b) => a.position.y - b.position.y) + + for (let i = 0; i < layer1.length - 1; i++) { + expect(layer1[i + 1].position.y).toBeGreaterThanOrEqual( + layer1[i].position.y + layer1[i].metrics.height + ) + } + }) +}) diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts index 94035ebe90d..a420db0f845 100644 --- a/apps/sim/lib/workflows/autolayout/core.ts +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -3,7 +3,6 @@ import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@sim/workflow-renderer' import { CONTAINER_LAYOUT_OPTIONS, DEFAULT_LAYOUT_OPTIONS, - MAX_OVERLAP_ITERATIONS, } from '@/lib/workflows/autolayout/constants' import type { Edge, GraphNode, LayoutOptions } from '@/lib/workflows/autolayout/types' import { @@ -200,57 +199,23 @@ export function groupByLayer(nodes: Map): Map() - for (const node of nodes) { - if (!nodesByLayer.has(node.layer)) { - nodesByLayer.set(node.layer, []) - } - nodesByLayer.get(node.layer)!.push(node) - } - - for (const [layer, layerNodes] of nodesByLayer) { - if (layerNodes.length < 2) continue - - layerNodes.sort((a, b) => a.position.y - b.position.y) +function resolveLayerOverlaps(layerNodes: GraphNode[], verticalSpacing: number): void { + if (layerNodes.length < 2) return - for (let i = 0; i < layerNodes.length - 1; i++) { - const node1 = layerNodes[i] - const node2 = layerNodes[i + 1] + const sorted = [...layerNodes].sort((a, b) => a.position.y - b.position.y) - const node1Bottom = node1.position.y + node1.metrics.height - const requiredY = node1Bottom + verticalSpacing - - if (node2.position.y < requiredY) { - hasOverlap = true - node2.position.y = requiredY - - logger.debug('Resolved vertical overlap in layer', { - layer, - block1: node1.id, - block2: node2.id, - iteration, - }) - } - } + for (let i = 0; i < sorted.length - 1; i++) { + const requiredY = sorted[i].position.y + sorted[i].metrics.height + verticalSpacing + if (sorted[i + 1].position.y < requiredY) { + sorted[i + 1].position.y = requiredY } } - - if (hasOverlap) { - logger.warn('Could not fully resolve all vertical overlaps after max iterations', { - iterations: MAX_OVERLAP_ITERATIONS, - }) - } } /** @@ -372,9 +337,9 @@ export function calculatePositions( node.position = { x: xPosition, y: bestSourceHandleY - targetHandleOffset } } - } - resolveVerticalOverlaps(Array.from(layers.values()).flat(), verticalSpacing) + resolveLayerOverlaps(nodesInLayer, verticalSpacing) + } } /**