Skip to content

Commit 0dd70b7

Browse files
fix(autolayout): targetted autolayout heuristic restored (#3536)
* fix(autolayout): targetted autolayout heuristic restored * fix autolayout boundary cases * more fixes * address comments * on conflict updates * address more comments * fix relative position scope * fix tye omission * address bugbot comment
1 parent 6b3ca1f commit 0dd70b7

File tree

9 files changed

+340
-349
lines changed

9 files changed

+340
-349
lines changed

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,12 @@ export function applyOperationsToWorkflowState(
238238
totalEdges: (modifiedState as any).edges?.length,
239239
})
240240
}
241+
// Remove edges that cross scope boundaries. This runs after all operations
242+
// and deferred connections are applied so that every block has its final
243+
// parentId. Running it per-operation would incorrectly drop edges between
244+
// blocks that are both being moved into the same subflow in one batch.
245+
removeInvalidScopeEdges(modifiedState)
246+
241247
// Regenerate loops and parallels after modifications
242248

243249
;(modifiedState as any).loops = generateLoopBlocks((modifiedState as any).blocks)
@@ -272,3 +278,46 @@ export function applyOperationsToWorkflowState(
272278

273279
return { state: modifiedState, validationErrors, skippedItems }
274280
}
281+
282+
/**
283+
* Removes edges that cross scope boundaries after all operations are applied.
284+
* An edge is invalid if:
285+
* - Either endpoint no longer exists (dangling reference)
286+
* - The source and target are in incompatible scopes
287+
* - A child block connects to its own parent container (non-handle edge)
288+
*
289+
* Valid scope relationships:
290+
* - Same scope: both blocks share the same parentId
291+
* - Container→child: source is the parent container of the target (start handles)
292+
* - Child→container: target is the parent container of the source (end handles)
293+
*/
294+
function removeInvalidScopeEdges(modifiedState: any): void {
295+
const blocks = modifiedState.blocks || {}
296+
const prevCount = modifiedState.edges?.length ?? 0
297+
298+
modifiedState.edges = (modifiedState.edges || []).filter((edge: any) => {
299+
const sourceBlock = blocks[edge.source]
300+
const targetBlock = blocks[edge.target]
301+
if (!sourceBlock || !targetBlock) return false
302+
303+
const sourceParent = sourceBlock.data?.parentId ?? null
304+
const targetParent = targetBlock.data?.parentId ?? null
305+
306+
// Same scope — always valid
307+
if (sourceParent === targetParent) return true
308+
309+
// Container→child (e.g., loop-start-source → first child in loop)
310+
if (targetParent === edge.source) return true
311+
312+
// Child→container (e.g., last child → loop-end via loop block edges)
313+
if (sourceParent === edge.target) return true
314+
315+
// Different scopes with no parent-child relationship — invalid
316+
return false
317+
})
318+
319+
const removed = prevCount - (modifiedState.edges?.length ?? 0)
320+
if (removed > 0) {
321+
logger.info('Removed invalid scope-crossing edges', { removed })
322+
}
323+
}

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { workflow as workflowTable } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { eq } from 'drizzle-orm'
55
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
6-
import { applyAutoLayout } from '@/lib/workflows/autolayout'
6+
import { applyTargetedLayout } from '@/lib/workflows/autolayout'
7+
import {
8+
DEFAULT_HORIZONTAL_SPACING,
9+
DEFAULT_VERTICAL_SPACING,
10+
} from '@/lib/workflows/autolayout/constants'
711
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
812
import {
913
loadWorkflowFromNormalizedTables,
@@ -234,21 +238,38 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
234238
// Persist the workflow state to the database
235239
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
236240

237-
// Apply autolayout to position blocks properly
238-
const layoutResult = applyAutoLayout(finalWorkflowState.blocks, finalWorkflowState.edges, {
239-
horizontalSpacing: 250,
240-
verticalSpacing: 100,
241-
padding: { x: 100, y: 100 },
241+
// Identify blocks that need layout by comparing against the pre-operation
242+
// state. New blocks and blocks inserted into subflows (position reset to
243+
// 0,0) need repositioning. Extracted blocks are excluded — their handler
244+
// already computed valid absolute positions from the container offset.
245+
const preOperationBlockIds = new Set(Object.keys(workflowState.blocks || {}))
246+
const blocksNeedingLayout = Object.keys(finalWorkflowState.blocks).filter((id) => {
247+
if (!preOperationBlockIds.has(id)) return true
248+
const prevParent = workflowState.blocks[id]?.data?.parentId ?? null
249+
const currParent = finalWorkflowState.blocks[id]?.data?.parentId ?? null
250+
if (prevParent === currParent) return false
251+
// Parent changed — only needs layout if position was reset to (0,0)
252+
// by insert_into_subflow. extract_from_subflow computes absolute
253+
// positions directly, so those blocks don't need repositioning.
254+
const pos = finalWorkflowState.blocks[id]?.position
255+
return pos?.x === 0 && pos?.y === 0
242256
})
243257

244-
const layoutedBlocks =
245-
layoutResult.success && layoutResult.blocks ? layoutResult.blocks : finalWorkflowState.blocks
258+
let layoutedBlocks = finalWorkflowState.blocks
246259

247-
if (!layoutResult.success) {
248-
logger.warn('Autolayout failed, using default positions', {
249-
workflowId,
250-
error: layoutResult.error,
251-
})
260+
if (blocksNeedingLayout.length > 0) {
261+
try {
262+
layoutedBlocks = applyTargetedLayout(finalWorkflowState.blocks, finalWorkflowState.edges, {
263+
changedBlockIds: blocksNeedingLayout,
264+
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
265+
verticalSpacing: DEFAULT_VERTICAL_SPACING,
266+
})
267+
} catch (error) {
268+
logger.warn('Targeted autolayout failed, using default positions', {
269+
workflowId,
270+
error: error instanceof Error ? error.message : String(error),
271+
})
272+
}
252273
}
253274

254275
const workflowStateForDb = {

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -798,12 +798,16 @@ export function handleInsertIntoSubflowOperation(
798798
return
799799
}
800800

801-
// Moving existing block into subflow - just update parent
801+
// Moving existing block into subflow — update parent and reset position.
802+
// Position must be reset because React Flow uses coordinates relative to
803+
// the parent container; keeping the old absolute position would place the
804+
// block far outside the container's bounds.
802805
existingBlock.data = {
803806
...existingBlock.data,
804807
parentId: subflowId,
805808
extent: 'parent' as const,
806809
}
810+
existingBlock.position = { x: 0, y: 0 }
807811

808812
// Update inputs if provided (with validation)
809813
if (params.inputs) {
@@ -981,12 +985,25 @@ export function handleExtractFromSubflowOperation(
981985
})
982986
}
983987

984-
// Remove parent relationship
988+
// Convert from relative (to container) to absolute position so the block
989+
// appears at roughly the same visual location after extraction. This avoids
990+
// needing targeted layout to reposition it — extracted blocks often lose
991+
// their edges to siblings still in the container, making them disconnected
992+
// and causing layout to stack them at layer 0.
993+
const container = modifiedState.blocks[subflowId]
994+
if (container?.position && block.position) {
995+
block.position = {
996+
x: (container.position.x ?? 0) + (block.position.x ?? 0),
997+
y: (container.position.y ?? 0) + (block.position.y ?? 0),
998+
}
999+
} else {
1000+
// Fallback to (0,0) which signals to blocksNeedingLayout in index.ts
1001+
// that this block requires targeted layout repositioning.
1002+
block.position = { x: 0, y: 0 }
1003+
}
1004+
9851005
if (block.data) {
9861006
block.data.parentId = undefined
9871007
block.data.extent = undefined
9881008
}
989-
990-
// Note: We keep the block and its edges, just remove parent relationship
991-
// The block becomes a root-level block
9921009
}

apps/sim/lib/workflows/autolayout/constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ export const AUTO_LAYOUT_EXCLUDED_TYPES = new Set(['note'])
7373
*/
7474
export const CONTAINER_BLOCK_TYPES = new Set(['loop', 'parallel'])
7575

76+
/**
77+
* Estimated height per subblock when no measured height is available.
78+
* Used as a heuristic for new blocks that haven't been rendered yet.
79+
*/
80+
export const ESTIMATED_SUBBLOCK_HEIGHT = 45
81+
82+
/**
83+
* Bottom padding added to estimated block height
84+
*/
85+
export const ESTIMATED_BLOCK_BOTTOM_PADDING = 20
86+
7687
/**
7788
* Default layout options
7889
*/

apps/sim/lib/workflows/autolayout/targeted.ts

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
filterLayoutEligibleBlockIds,
1111
getBlockMetrics,
1212
getBlocksByParent,
13-
isContainerType,
1413
prepareContainerDimensions,
1514
shouldSkipAutoLayout,
1615
snapPositionToGrid,
@@ -90,7 +89,39 @@ export function applyTargetedLayout(
9089
}
9190

9291
/**
93-
* Layouts a group of blocks (either root level or within a container)
92+
* Selects the best anchor block for offset computation.
93+
* Prefers an unchanged block that is a direct edge-neighbor of a block
94+
* that needs layout, so the offset aligns new blocks relative to their
95+
* actual graph neighbors rather than an arbitrary/outlier block.
96+
*/
97+
function selectBestAnchor(
98+
eligibleIds: string[],
99+
needsLayoutSet: Set<string>,
100+
edges: Edge[],
101+
layoutPositions: Map<string, { x: number; y: number }>
102+
): string | undefined {
103+
const candidates = eligibleIds.filter((id) => !needsLayoutSet.has(id) && layoutPositions.has(id))
104+
if (candidates.length === 0) return undefined
105+
if (candidates.length === 1) return candidates[0]
106+
107+
const candidateSet = new Set(candidates)
108+
109+
for (const edge of edges) {
110+
if (needsLayoutSet.has(edge.source) && candidateSet.has(edge.target)) {
111+
return edge.target
112+
}
113+
if (needsLayoutSet.has(edge.target) && candidateSet.has(edge.source)) {
114+
return edge.source
115+
}
116+
}
117+
118+
return candidates[0]
119+
}
120+
121+
/**
122+
* Layouts a group of blocks (either root level or within a container).
123+
* Only repositions blocks in `changedSet` or those with invalid positions;
124+
* all other blocks act as anchors.
94125
*/
95126
function layoutGroup(
96127
parentId: string | null,
@@ -119,24 +150,20 @@ function layoutGroup(
119150
const requestedLayout = layoutEligibleChildIds.filter((id) => {
120151
const block = blocks[id]
121152
if (!block) return false
122-
if (isContainerType(block.type)) {
123-
return changedSet.has(id) && isDefaultPosition(block)
124-
}
125153
return changedSet.has(id)
126154
})
127-
const missingPositions = layoutEligibleChildIds.filter((id) => {
155+
const invalidPositions = layoutEligibleChildIds.filter((id) => {
128156
const block = blocks[id]
129157
if (!block) return false
130-
return !hasPosition(block) || isDefaultPosition(block)
158+
return !hasPosition(block)
131159
})
132-
const needsLayoutSet = new Set([...requestedLayout, ...missingPositions])
160+
const needsLayoutSet = new Set([...requestedLayout, ...invalidPositions])
133161
const needsLayout = Array.from(needsLayoutSet)
134162

135-
if (parentBlock) {
136-
updateContainerDimensions(parentBlock, childIds, blocks)
137-
}
138-
139163
if (needsLayout.length === 0) {
164+
if (parentBlock) {
165+
updateContainerDimensions(parentBlock, childIds, blocks)
166+
}
140167
return
141168
}
142169

@@ -168,9 +195,7 @@ function layoutGroup(
168195
let offsetX = 0
169196
let offsetY = 0
170197

171-
const anchorId = layoutEligibleChildIds.find(
172-
(id) => !needsLayout.includes(id) && layoutPositions.has(id)
173-
)
198+
const anchorId = selectBestAnchor(layoutEligibleChildIds, needsLayoutSet, edges, layoutPositions)
174199

175200
if (anchorId) {
176201
const oldPos = oldPositions.get(anchorId)
@@ -187,6 +212,10 @@ function layoutGroup(
187212
if (!block || !newPos) continue
188213
block.position = snapPositionToGrid({ x: newPos.x + offsetX, y: newPos.y + offsetY }, gridSize)
189214
}
215+
216+
if (parentBlock) {
217+
updateContainerDimensions(parentBlock, childIds, blocks)
218+
}
190219
}
191220

192221
/**
@@ -304,20 +333,11 @@ function updateContainerDimensions(
304333
}
305334

306335
/**
307-
* Checks if a block has a valid position
336+
* Checks if a block has a valid, finite position.
337+
* Returns false for missing, undefined, NaN, or Infinity coordinates.
308338
*/
309339
function hasPosition(block: BlockState): boolean {
310340
if (!block.position) return false
311341
const { x, y } = block.position
312342
return Number.isFinite(x) && Number.isFinite(y)
313343
}
314-
315-
/**
316-
* Checks if a block is at the default/uninitialized position (0, 0).
317-
* New blocks typically start at this position before being laid out.
318-
*/
319-
function isDefaultPosition(block: BlockState): boolean {
320-
if (!block.position) return true
321-
const { x, y } = block.position
322-
return x === 0 && y === 0
323-
}

apps/sim/lib/workflows/autolayout/utils.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
CONTAINER_PADDING,
55
CONTAINER_PADDING_X,
66
CONTAINER_PADDING_Y,
7+
ESTIMATED_BLOCK_BOTTOM_PADDING,
8+
ESTIMATED_SUBBLOCK_HEIGHT,
79
ROOT_PADDING_X,
810
ROOT_PADDING_Y,
911
} from '@/lib/workflows/autolayout/constants'
@@ -131,16 +133,36 @@ function getContainerMetrics(block: BlockState): BlockMetrics {
131133
}
132134

133135
/**
134-
* Gets metrics for a regular (non-container) block
136+
* Estimates block height from subblock count when no measurement is available.
137+
* Provides a reasonable approximation to prevent overlaps in layout before
138+
* the block has been rendered and measured by the browser.
139+
*/
140+
function estimateBlockHeight(block: BlockState): number {
141+
const subBlockCount = Object.keys(block.subBlocks || {}).length
142+
if (subBlockCount === 0) return BLOCK_DIMENSIONS.MIN_HEIGHT
143+
144+
return Math.max(
145+
BLOCK_DIMENSIONS.HEADER_HEIGHT +
146+
subBlockCount * ESTIMATED_SUBBLOCK_HEIGHT +
147+
ESTIMATED_BLOCK_BOTTOM_PADDING,
148+
BLOCK_DIMENSIONS.MIN_HEIGHT
149+
)
150+
}
151+
152+
/**
153+
* Gets metrics for a regular (non-container) block.
154+
* Falls back to subblock-based height estimation when no measurement exists,
155+
* which prevents overlaps for newly added blocks that haven't been rendered.
135156
*/
136157
function getRegularBlockMetrics(block: BlockState): BlockMetrics {
137158
const minWidth = BLOCK_DIMENSIONS.FIXED_WIDTH
138159
const minHeight = BLOCK_DIMENSIONS.MIN_HEIGHT
139160
const measuredH = block.layout?.measuredHeight ?? block.height
140161
const measuredW = block.layout?.measuredWidth
141162

163+
const hasMeasurement = typeof measuredH === 'number' && measuredH > 0
164+
const height = hasMeasurement ? Math.max(measuredH, minHeight) : estimateBlockHeight(block)
142165
const width = Math.max(measuredW ?? minWidth, minWidth)
143-
const height = Math.max(measuredH ?? minHeight, minHeight)
144166

145167
return {
146168
width,

apps/sim/lib/workflows/diff/diff-engine.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ vi.mock('@/lib/workflows/sanitization/key-validation', () => ({
2626
vi.mock('@/lib/workflows/autolayout', () => ({
2727
transferBlockHeights: vi.fn(),
2828
applyTargetedLayout: (blocks: Record<string, BlockState>) => blocks,
29-
applyAutoLayout: () => ({ success: true, blocks: {} }),
3029
}))
3130

3231
vi.mock('@/lib/workflows/autolayout/constants', () => ({

0 commit comments

Comments
 (0)