Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 7 additions & 18 deletions apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
})
Expand Down
343 changes: 343 additions & 0 deletions apps/sim/lib/workflows/autolayout/change-set.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
/**
* @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('includes newly added blocks', () => {
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(['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([])
})
})
Loading
Loading