mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 13:00:04 +08:00
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:
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user