fix(autolayout): edits coalesced for same request diffs (#3724)

* fix(autolayout): edits coalesced for same request diffs

* address comments

* address edge signature gen

* perf improvement
This commit is contained in:
Vikhyath Mondreti
2026-03-23 16:59:07 -07:00
committed by GitHub
parent 288aa0851b
commit 767db1ce3a
8 changed files with 806 additions and 94 deletions
@@ -7,7 +7,7 @@ import {
type BaseServerTool,
type ServerToolContext,
} from '@/lib/copilot/tools/server/base-tool'
import { applyTargetedLayout } from '@/lib/workflows/autolayout'
import { applyTargetedLayout, getTargetedLayoutImpact } from '@/lib/workflows/autolayout'
import {
DEFAULT_HORIZONTAL_SPACING,
DEFAULT_VERTICAL_SPACING,
@@ -233,29 +233,18 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
// Persist the workflow state to the database
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
// Identify blocks that need layout by comparing against the pre-operation
// state. New blocks and blocks inserted into subflows (position reset to
// 0,0) need repositioning. Extracted blocks are excluded — their handler
// already computed valid absolute positions from the container offset.
const preOperationBlockIds = new Set(Object.keys(workflowState.blocks || {}))
const blocksNeedingLayout = Object.keys(finalWorkflowState.blocks).filter((id) => {
if (!preOperationBlockIds.has(id)) return true
const prevParent = workflowState.blocks[id]?.data?.parentId ?? null
const currParent = finalWorkflowState.blocks[id]?.data?.parentId ?? null
if (prevParent === currParent) return false
// Parent changed — only needs layout if position was reset to (0,0)
// by insert_into_subflow. extract_from_subflow computes absolute
// positions directly, so those blocks don't need repositioning.
const pos = finalWorkflowState.blocks[id]?.position
return pos?.x === 0 && pos?.y === 0
const { layoutBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
before: workflowState,
after: finalWorkflowState,
})
let layoutedBlocks = finalWorkflowState.blocks
if (blocksNeedingLayout.length > 0) {
if (layoutBlockIds.length > 0 || shiftSourceBlockIds.length > 0) {
try {
layoutedBlocks = applyTargetedLayout(finalWorkflowState.blocks, finalWorkflowState.edges, {
changedBlockIds: blocksNeedingLayout,
changedBlockIds: layoutBlockIds,
shiftSourceBlockIds,
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,
})
@@ -0,0 +1,360 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
getTargetedLayoutChangeSet,
getTargetedLayoutImpact,
} from '@/lib/workflows/autolayout/change-set'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
function createBlock(
id: string,
overrides: Partial<BlockState> = {},
parentId?: string
): BlockState {
return {
id,
type: 'agent',
name: id,
position: { x: 100, y: 100 },
subBlocks: {},
outputs: {},
enabled: true,
...(parentId ? { data: { parentId, extent: 'parent' as const } } : {}),
...overrides,
}
}
function createWorkflowState({
blocks,
edges = [],
}: {
blocks: Record<string, BlockState>
edges?: WorkflowState['edges']
}): Pick<WorkflowState, 'blocks' | 'edges'> {
return {
blocks,
edges,
}
}
describe('getTargetedLayoutChangeSet', () => {
it('does not relayout newly added blocks that already have valid positions', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start'),
},
})
const after = createWorkflowState({
blocks: {
start: createBlock('start'),
agent: createBlock('agent', { position: { x: 400, y: 100 } }),
},
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
})
it('includes newly added blocks when they still have sentinel positions', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start'),
},
})
const after = createWorkflowState({
blocks: {
start: createBlock('start'),
agent: createBlock('agent', { position: { x: 0, y: 0 } }),
},
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual(['agent'])
})
it('keeps subblock-only edits anchored', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start'),
},
})
const after = createWorkflowState({
blocks: {
start: createBlock('start', {
subBlocks: {
prompt: {
id: 'prompt',
type: 'long-input',
value: 'updated',
},
},
}),
},
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
})
it('does not relayout a pre-existing block legitimately placed at the origin', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start', { position: { x: 0, y: 0 } }),
},
})
const after = createWorkflowState({
blocks: {
start: createBlock('start', {
position: { x: 0, y: 0 },
subBlocks: {
prompt: {
id: 'prompt',
type: 'long-input',
value: 'updated',
},
},
}),
},
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
})
it('reopens only the downstream path when an edge is added later', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start'),
function1: createBlock('function1', { position: { x: 400, y: 100 } }),
end: createBlock('end', { position: { x: 700, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'function1',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
const after = createWorkflowState({
blocks: {
start: createBlock('start'),
function1: createBlock('function1', { position: { x: 400, y: 100 } }),
end: createBlock('end', { position: { x: 700, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'function1',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-2',
source: 'start',
target: 'function1',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: ['function1'],
shiftSourceBlockIds: [],
})
})
it('returns a pure shift source when a stable block gains an edge to an already-connected target', () => {
const before = createWorkflowState({
blocks: {
source: createBlock('source', { position: { x: 100, y: 100 } }),
upstream: createBlock('upstream', { position: { x: 100, y: 300 } }),
target: createBlock('target', { position: { x: 400, y: 100 } }),
end: createBlock('end', { position: { x: 700, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'upstream',
target: 'target',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-2',
source: 'target',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
const after = createWorkflowState({
blocks: {
source: createBlock('source', { position: { x: 100, y: 100 } }),
upstream: createBlock('upstream', { position: { x: 100, y: 300 } }),
target: createBlock('target', { position: { x: 400, y: 100 } }),
end: createBlock('end', { position: { x: 700, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'upstream',
target: 'target',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-2',
source: 'target',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-3',
source: 'source',
target: 'target',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: [],
shiftSourceBlockIds: ['source'],
})
})
it('distinguishes added edges when ids and handles contain hyphens', () => {
const before = createWorkflowState({
blocks: {
a: createBlock('a', { position: { x: 100, y: 100 } }),
'a-b': createBlock('a-b', { position: { x: 100, y: 300 } }),
target: createBlock('target', { position: { x: 400, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'a',
sourceHandle: 'b-c',
target: 'target',
targetHandle: 'target',
},
],
})
const after = createWorkflowState({
blocks: {
a: createBlock('a', { position: { x: 100, y: 100 } }),
'a-b': createBlock('a-b', { position: { x: 100, y: 300 } }),
target: createBlock('target', { position: { x: 400, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'a',
sourceHandle: 'b-c',
target: 'target',
targetHandle: 'target',
},
{
id: 'edge-2',
source: 'a-b',
sourceHandle: 'c',
target: 'target',
targetHandle: 'target',
},
],
})
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: [],
shiftSourceBlockIds: ['a-b'],
})
})
it('keeps the upstream source anchored when inserting between existing blocks', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start'),
end: createBlock('end', { position: { x: 700, y: 100 } }),
inserted: createBlock('inserted', { position: { x: 400, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'start',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
const after = createWorkflowState({
blocks: {
start: createBlock('start'),
end: createBlock('end', { position: { x: 700, y: 100 } }),
inserted: createBlock('inserted', { position: { x: 400, y: 100 } }),
},
edges: [
{
id: 'edge-2',
source: 'start',
target: 'inserted',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-3',
source: 'inserted',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: ['inserted'],
shiftSourceBlockIds: ['inserted'],
})
})
it('ignores edge changes that cross layout scopes', () => {
const before = createWorkflowState({
blocks: {
loop: createBlock('loop'),
child: createBlock('child', { position: { x: 120, y: 160 } }, 'loop'),
},
})
const after = createWorkflowState({
blocks: {
loop: createBlock('loop'),
child: createBlock('child', { position: { x: 120, y: 160 } }, 'loop'),
},
edges: [
{
id: 'edge-1',
source: 'loop',
target: 'child',
sourceHandle: 'loop-start-source',
targetHandle: 'target',
},
],
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
})
})
@@ -0,0 +1,198 @@
import type { Edge } from 'reactflow'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
interface TargetedLayoutChangeSetOptions {
before: Pick<WorkflowState, 'blocks' | 'edges'>
after: Pick<WorkflowState, 'blocks' | 'edges'>
}
export interface TargetedLayoutImpact {
layoutBlockIds: string[]
shiftSourceBlockIds: string[]
}
/**
* Computes the minimal structural change set that should be reopened for
* targeted layout after a workflow edit.
*/
export function getTargetedLayoutImpact({
before,
after,
}: TargetedLayoutChangeSetOptions): TargetedLayoutImpact {
const layoutBlockIds = new Set<string>()
const afterBlockIds = new Set(Object.keys(after.blocks || {}))
const beforeBlockIds = new Set(Object.keys(before.blocks || {}))
for (const blockId of afterBlockIds) {
if (!beforeBlockIds.has(blockId)) {
const position = after.blocks[blockId]?.position
if (
!position ||
!Number.isFinite(position.x) ||
!Number.isFinite(position.y) ||
(position.x === 0 && position.y === 0)
) {
layoutBlockIds.add(blockId)
}
continue
}
const previousParentId = before.blocks[blockId]?.data?.parentId ?? null
const currentParentId = after.blocks[blockId]?.data?.parentId ?? null
if (previousParentId === currentParentId) {
continue
}
const position = after.blocks[blockId]?.position
if (position?.x === 0 && position?.y === 0) {
layoutBlockIds.add(blockId)
}
}
for (const blockId of getBlocksWithInvalidPositions(after, beforeBlockIds)) {
layoutBlockIds.add(blockId)
}
const addedEdges = getAddedLayoutScopedEdges(before.edges || [], after.edges || [], after.blocks)
if (addedEdges.length === 0) {
return {
layoutBlockIds: Array.from(layoutBlockIds),
shiftSourceBlockIds: [],
}
}
const beforeIncomingCounts = countIncomingLayoutScopedEdges(before.edges || [], before.blocks)
const afterIncomingCounts = countIncomingLayoutScopedEdges(after.edges || [], after.blocks)
for (const edge of addedEdges) {
const targetBlock = after.blocks[edge.target]
if (!targetBlock) {
continue
}
const beforeIncoming = beforeIncomingCounts.get(edge.target) ?? 0
const afterIncoming = afterIncomingCounts.get(edge.target) ?? 0
if (beforeIncoming === 0 && afterIncoming > 0) {
layoutBlockIds.add(edge.target)
}
}
const shiftSourceBlockIds = new Set<string>()
for (const edge of addedEdges) {
if (!after.blocks[edge.source] || !after.blocks[edge.target]) {
continue
}
if (layoutBlockIds.has(edge.target)) {
continue
}
shiftSourceBlockIds.add(edge.source)
}
return {
layoutBlockIds: Array.from(layoutBlockIds),
shiftSourceBlockIds: Array.from(shiftSourceBlockIds),
}
}
export function getTargetedLayoutChangeSet(options: TargetedLayoutChangeSetOptions): string[] {
return getTargetedLayoutImpact(options).layoutBlockIds
}
/**
* Returns block IDs that cannot be treated as stable layout anchors.
* Existing blocks are only considered invalid for missing or non-finite
* coordinates; `(0,0)` is reserved as a layout sentinel only for newly added
* blocks and parent-change handling above.
*/
function getBlocksWithInvalidPositions(
after: Pick<WorkflowState, 'blocks'>,
beforeBlockIds: Set<string>
): string[] {
return Object.keys(after.blocks || {}).filter((blockId) => {
const position = after.blocks[blockId]?.position
return (
!position ||
!Number.isFinite(position.x) ||
!Number.isFinite(position.y) ||
(!beforeBlockIds.has(blockId) && position.x === 0 && position.y === 0)
)
})
}
/**
* Returns added edges that participate in layout within a shared parent scope.
*/
function getAddedLayoutScopedEdges(
beforeEdges: Edge[],
afterEdges: Edge[],
afterBlocks: WorkflowState['blocks']
): Edge[] {
const beforeSignatures = new Set(beforeEdges.map((edge) => getEdgeSignature(edge)))
const addedEdges: Edge[] = []
for (const edge of afterEdges) {
if (beforeSignatures.has(getEdgeSignature(edge))) {
continue
}
if (isLayoutScopedEdge(edge, afterBlocks)) {
addedEdges.push(edge)
}
}
return addedEdges
}
/**
* Counts incoming edges that participate in layout within each shared parent
* scope for the provided workflow snapshot.
*/
function countIncomingLayoutScopedEdges(
edges: Edge[],
blocks: WorkflowState['blocks']
): Map<string, number> {
const counts = new Map<string, number>()
for (const edge of edges) {
if (!isLayoutScopedEdge(edge, blocks)) {
continue
}
counts.set(edge.target, (counts.get(edge.target) ?? 0) + 1)
}
return counts
}
/**
* Layout groups are scoped by parent container, so only edges whose endpoints
* share the same current parent can affect the group's block positions.
*/
function isLayoutScopedEdge(edge: Edge, afterBlocks: WorkflowState['blocks']): boolean {
const sourceBlock = afterBlocks[edge.source]
const targetBlock = afterBlocks[edge.target]
if (!sourceBlock || !targetBlock) {
return false
}
const sourceParentId = sourceBlock.data?.parentId ?? null
const targetParentId = targetBlock.data?.parentId ?? null
return sourceParentId === targetParentId
}
/**
* Creates a stable signature for comparing workflow edges independent of edge
* record IDs.
*/
function getEdgeSignature(edge: Edge): string {
return JSON.stringify([
edge.source,
edge.sourceHandle || 'source',
edge.target,
edge.targetHandle || 'target',
])
}
@@ -91,6 +91,10 @@ export function applyAutoLayout(
}
}
export {
getTargetedLayoutChangeSet,
getTargetedLayoutImpact,
} from '@/lib/workflows/autolayout/change-set'
export type { TargetedLayoutOptions } from '@/lib/workflows/autolayout/targeted'
export { applyTargetedLayout } from '@/lib/workflows/autolayout/targeted'
export type { Edge, LayoutOptions, LayoutResult } from '@/lib/workflows/autolayout/types'
@@ -0,0 +1,151 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { applyTargetedLayout } from '@/lib/workflows/autolayout/targeted'
import type { Edge } from '@/lib/workflows/autolayout/types'
import { getBlockMetrics } from '@/lib/workflows/autolayout/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
function createBlock(id: string, overrides: Partial<BlockState> = {}): BlockState {
return {
id,
type: 'function',
name: id,
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
...overrides,
}
}
describe('applyTargetedLayout', () => {
it('shifts downstream frozen blocks when only shift sources are provided', () => {
const blocks = {
source: createBlock('source', {
position: { x: 100, y: 100 },
}),
target: createBlock('target', {
position: { x: 400, y: 100 },
}),
end: createBlock('end', {
position: { x: 760, y: 100 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'source',
target: 'target',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-2',
source: 'target',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: [],
shiftSourceBlockIds: ['source'],
})
expect(result.source.position).toEqual({ x: 100, y: 100 })
expect(result.target.position).toEqual({ x: 530, y: 100 })
expect(result.end.position).toEqual({ x: 960, y: 100 })
})
it('places new linear blocks without moving anchors', () => {
const blocks = {
anchor: createBlock('anchor', {
position: { x: 150, y: 150 },
}),
changed: createBlock('changed', {
position: { x: 0, y: 0 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'anchor',
target: 'changed',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: ['changed'],
})
expect(result.anchor.position).toEqual({ x: 150, y: 150 })
expect(result.changed.position.x).toBeGreaterThan(result.anchor.position.x)
expect(result.changed.position.y).toBe(result.anchor.position.y)
})
it('keeps root-level insertions closer to anchored blocks near the top of the canvas', () => {
const blocks = {
start: createBlock('start', {
position: { x: 0, y: 0 },
}),
changed: createBlock('changed', {
position: { x: 0, y: 0 },
}),
agent: createBlock('agent', {
position: { x: 410.94, y: 2.33 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'start',
target: 'changed',
},
{
id: 'edge-2',
source: 'changed',
target: 'agent',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: ['changed'],
})
expect(result.changed.position.y).toBeLessThan(150)
})
it('places new parallel children below tall anchored siblings', () => {
const blocks = {
parallel: createBlock('parallel', {
type: 'parallel',
position: { x: 200, y: 150 },
data: { width: 600, height: 500 },
layout: { measuredWidth: 600, measuredHeight: 500 },
}),
existing: createBlock('existing', {
position: { x: 180, y: 100 },
data: { parentId: 'parallel', extent: 'parent' },
layout: { measuredWidth: 250, measuredHeight: 220 },
height: 220,
}),
changed: createBlock('changed', {
position: { x: 0, y: 0 },
data: { parentId: 'parallel', extent: 'parent' },
}),
}
const result = applyTargetedLayout(blocks, [], {
changedBlockIds: ['changed'],
})
const existingMetrics = getBlockMetrics(result.existing)
expect(result.parallel.position).toEqual({ x: 200, y: 150 })
expect(result.changed.position.y).toBeGreaterThanOrEqual(
result.existing.position.y + existingMetrics.height
)
})
})
+71 -49
View File
@@ -20,6 +20,7 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
export interface TargetedLayoutOptions extends LayoutOptions {
changedBlockIds: string[]
shiftSourceBlockIds?: string[]
verticalSpacing?: number
horizontalSpacing?: number
}
@@ -35,16 +36,18 @@ export function applyTargetedLayout(
): Record<string, BlockState> {
const {
changedBlockIds,
shiftSourceBlockIds = [],
verticalSpacing = DEFAULT_VERTICAL_SPACING,
horizontalSpacing = DEFAULT_HORIZONTAL_SPACING,
gridSize,
} = options
if (!changedBlockIds || changedBlockIds.length === 0) {
if ((!changedBlockIds || changedBlockIds.length === 0) && shiftSourceBlockIds.length === 0) {
return blocks
}
const changedSet = new Set(changedBlockIds)
const shiftSourceSet = new Set(shiftSourceBlockIds)
const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks))
prepareContainerDimensions(
@@ -66,6 +69,7 @@ export function applyTargetedLayout(
blocksCopy,
edges,
changedSet,
shiftSourceSet,
verticalSpacing,
horizontalSpacing,
subflowDepths,
@@ -79,6 +83,7 @@ export function applyTargetedLayout(
blocksCopy,
edges,
changedSet,
shiftSourceSet,
verticalSpacing,
horizontalSpacing,
subflowDepths,
@@ -134,6 +139,7 @@ function layoutGroup(
blocks: Record<string, BlockState>,
edges: Edge[],
changedSet: Set<string>,
shiftSourceSet: Set<string>,
verticalSpacing: number,
horizontalSpacing: number,
subflowDepths: Map<string, number>,
@@ -164,61 +170,74 @@ function layoutGroup(
})
const needsLayoutSet = new Set([...requestedLayout, ...invalidPositions])
const needsLayout = Array.from(needsLayoutSet)
const groupShiftSourceIds = layoutEligibleChildIds.filter((id) => shiftSourceSet.has(id))
const activeShiftSourceSet = new Set([...needsLayoutSet, ...groupShiftSourceIds])
if (needsLayout.length === 0) {
if (needsLayout.length === 0 && activeShiftSourceSet.size === 0) {
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
return
}
const oldPositions = new Map<string, { x: number; y: number }>()
for (const id of layoutEligibleChildIds) {
const block = blocks[id]
if (!block) continue
oldPositions.set(id, { ...block.position })
}
const layoutPositions = computeLayoutPositions(
layoutEligibleChildIds,
blocks,
edges,
parentBlock,
horizontalSpacing,
verticalSpacing,
parentId === null ? subflowDepths : undefined,
gridSize
)
if (layoutPositions.size === 0) {
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
if (needsLayout.length > 0) {
const oldPositions = new Map<string, { x: number; y: number }>()
for (const id of layoutEligibleChildIds) {
const block = blocks[id]
if (!block) continue
oldPositions.set(id, { ...block.position })
}
return
}
let offsetX = 0
let offsetY = 0
const layoutPositions = computeLayoutPositions(
layoutEligibleChildIds,
blocks,
edges,
parentBlock,
horizontalSpacing,
verticalSpacing,
parentId === null ? subflowDepths : undefined,
gridSize
)
const anchorId = selectBestAnchor(layoutEligibleChildIds, needsLayoutSet, edges, layoutPositions)
if (anchorId) {
const oldPos = oldPositions.get(anchorId)
const newPos = layoutPositions.get(anchorId)
if (oldPos && newPos) {
offsetX = oldPos.x - newPos.x
offsetY = oldPos.y - newPos.y
if (layoutPositions.size === 0) {
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
return
}
}
for (const id of needsLayout) {
const block = blocks[id]
const newPos = layoutPositions.get(id)
if (!block || !newPos) continue
block.position = snapPositionToGrid({ x: newPos.x + offsetX, y: newPos.y + offsetY }, gridSize)
let offsetX = 0
let offsetY = 0
const anchorId = selectBestAnchor(
layoutEligibleChildIds,
needsLayoutSet,
edges,
layoutPositions
)
if (anchorId) {
const oldPos = oldPositions.get(anchorId)
const newPos = layoutPositions.get(anchorId)
if (oldPos && newPos) {
offsetX = oldPos.x - newPos.x
offsetY = oldPos.y - newPos.y
}
}
for (const id of needsLayout) {
const block = blocks[id]
const newPos = layoutPositions.get(id)
if (!block || !newPos) continue
block.position = snapPositionToGrid(
{ x: newPos.x + offsetX, y: newPos.y + offsetY },
gridSize
)
}
}
shiftDownstreamFrozenBlocks(
activeShiftSourceSet,
needsLayoutSet,
layoutEligibleChildIds,
blocks,
@@ -227,13 +246,15 @@ function layoutGroup(
gridSize
)
resolveVerticalOverlapsWithFrozen(
needsLayoutSet,
layoutEligibleChildIds,
blocks,
verticalSpacing,
gridSize
)
if (needsLayout.length > 0) {
resolveVerticalOverlapsWithFrozen(
needsLayoutSet,
layoutEligibleChildIds,
blocks,
verticalSpacing,
gridSize
)
}
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
@@ -249,6 +270,7 @@ function layoutGroup(
* Only considers edges within the current layout group (scoped to subflow).
*/
function shiftDownstreamFrozenBlocks(
shiftSourceSet: Set<string>,
needsLayoutSet: Set<string>,
eligibleIds: string[],
blocks: Record<string, BlockState>,
@@ -266,7 +288,7 @@ function shiftDownstreamFrozenBlocks(
}
const shifted = new Set<string>()
const queue: string[] = Array.from(needsLayoutSet)
const queue: string[] = Array.from(shiftSourceSet)
while (queue.length > 0) {
const sourceId = queue.shift()!
@@ -26,6 +26,10 @@ vi.mock('@/lib/workflows/sanitization/key-validation', () => ({
vi.mock('@/lib/workflows/autolayout', () => ({
transferBlockHeights: vi.fn(),
applyTargetedLayout: (blocks: Record<string, BlockState>) => blocks,
getTargetedLayoutImpact: () => ({
layoutBlockIds: [],
shiftSourceBlockIds: [],
}),
}))
vi.mock('@/lib/workflows/autolayout/constants', () => ({
+11 -27
View File
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { getTargetedLayoutImpact } from '@/lib/workflows/autolayout'
import type { BlockWithDiff } from '@/lib/workflows/diff/types'
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
import { isUuid } from '@/executor/constants'
@@ -503,33 +504,14 @@ export class WorkflowDiffEngine {
// Apply autolayout to the proposed state
logger.info('Applying autolayout to proposed workflow state')
try {
const baselineBlockIds = new Set(Object.keys(mergedBaseline.blocks))
// Identify blocks that need positioning: genuinely new blocks that
// don't have valid positions yet. Blocks already positioned by a
// previous server-side layout (non-origin position) are skipped to
// avoid redundant client-side re-layout that can shift blocks when
// block metrics change between edits (e.g. condition handle offsets).
const blocksNeedingLayout = Object.keys(finalBlocks).filter((id) => {
if (!baselineBlockIds.has(id)) {
const pos = finalBlocks[id]?.position
const hasValidPosition =
pos &&
Number.isFinite(pos.x) &&
Number.isFinite(pos.y) &&
!(pos.x === 0 && pos.y === 0)
return !hasValidPosition
}
const baselineParent = mergedBaseline.blocks[id]?.data?.parentId ?? null
const proposedParent = finalBlocks[id]?.data?.parentId ?? null
if (baselineParent === proposedParent) return false
const pos = finalBlocks[id]?.position
return pos?.x === 0 && pos?.y === 0
const { layoutBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
before: mergedBaseline,
after: fullyCleanedState,
})
const totalBlocks = Object.keys(finalBlocks).length
if (blocksNeedingLayout.length === 0) {
if (layoutBlockIds.length === 0 && shiftSourceBlockIds.length === 0) {
logger.info('No blocks need layout; skipping autolayout', {
totalBlocks,
})
@@ -540,8 +522,9 @@ export class WorkflowDiffEngine {
// gracefully to a full layout from the padding origin — same result
// as applyAutoLayout but with one unified code path.
logger.info('Using targeted layout for copilot edits', {
blocksNeedingLayout: blocksNeedingLayout.length,
anchors: totalBlocks - blocksNeedingLayout.length,
blocksNeedingLayout: layoutBlockIds.length,
shiftSourceBlocks: shiftSourceBlockIds.length,
anchors: totalBlocks - layoutBlockIds.length,
totalBlocks,
})
@@ -551,7 +534,8 @@ export class WorkflowDiffEngine {
)
const layoutedBlocks = applyTargetedLayout(finalBlocks, fullyCleanedState.edges, {
changedBlockIds: blocksNeedingLayout,
changedBlockIds: layoutBlockIds,
shiftSourceBlockIds,
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,
})
@@ -582,7 +566,7 @@ export class WorkflowDiffEngine {
logger.info('Successfully applied targeted layout to proposed state', {
blocksLayouted: Object.keys(layoutedBlocks).length,
blocksNeedingLayout: blocksNeedingLayout.length,
blocksNeedingLayout: layoutBlockIds.length,
})
}
} catch (layoutError) {