improved autolayout

This commit is contained in:
Vikhyath Mondreti
2026-04-10 12:11:52 -07:00
parent e2b4eb370d
commit da28f8a4b8
9 changed files with 584 additions and 78 deletions

View File

@@ -10,7 +10,8 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { createMcpToolId } from '@/lib/mcp/shared'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import type { FilterRule, SortRule } from '@/lib/table/types'
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
import {
buildCanonicalIndex,
@@ -1145,33 +1146,14 @@ export const WorkflowBlock = memo(function WorkflowBlock({
useBlockDimensions({
blockId: id,
calculateDimensions: () => {
const shouldShowDefaultHandles =
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
let rowsCount = 0
if (type === 'condition') {
rowsCount = conditionRows.length + defaultHandlesRow
} else if (type === 'router_v2') {
// +1 for context row, plus route rows
rowsCount = 1 + routerRows.length + defaultHandlesRow
} else {
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
rowsCount = subblockRowCount + defaultHandlesRow
}
const contentHeight = hasContentBelowHeader
? BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
rowsCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
: 0
const calculatedHeight = Math.max(
BLOCK_DIMENSIONS.HEADER_HEIGHT + contentHeight,
BLOCK_DIMENSIONS.MIN_HEIGHT
)
return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: calculatedHeight }
return calculateWorkflowBlockDimensions({
blockType: type,
category: config.category,
displayTriggerMode,
visibleSubBlockCount: subBlockRows.reduce((acc, row) => acc + row.length, 0),
conditionRowCount: conditionRows.length,
routerRowCount: routerRows.length,
})
},
dependencies: [
type,

View File

@@ -9,7 +9,11 @@ import {
type ServerToolContext,
} from '@/lib/copilot/tools/server/base-tool'
import { env } from '@/lib/core/config/env'
import { applyTargetedLayout, getTargetedLayoutImpact } from '@/lib/workflows/autolayout'
import {
applyTargetedLayout,
getTargetedLayoutImpact,
transferBlockHeights,
} from '@/lib/workflows/autolayout'
import {
DEFAULT_HORIZONTAL_SPACING,
DEFAULT_VERTICAL_SPACING,
@@ -235,17 +239,19 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
// Persist the workflow state to the database
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
const { layoutBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
before: workflowState,
after: finalWorkflowState,
})
let layoutedBlocks = finalWorkflowState.blocks
if (layoutBlockIds.length > 0 || shiftSourceBlockIds.length > 0) {
if (layoutBlockIds.length > 0 || resizedBlockIds.length > 0 || shiftSourceBlockIds.length > 0) {
try {
transferBlockHeights(workflowState.blocks, finalWorkflowState.blocks)
layoutedBlocks = applyTargetedLayout(finalWorkflowState.blocks, finalWorkflowState.edges, {
changedBlockIds: layoutBlockIds,
resizedBlockIds,
shiftSourceBlockIds,
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,

View File

@@ -1,13 +1,43 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getTargetedLayoutChangeSet,
getTargetedLayoutImpact,
} from '@/lib/workflows/autolayout/change-set'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const { mockGetBlock } = vi.hoisted(() => ({
mockGetBlock: vi.fn(),
}))
vi.mock('@/blocks', () => ({
getBlock: mockGetBlock,
}))
const JIRA_TEST_BLOCK_CONFIG = {
category: 'tools',
subBlocks: [
{ id: 'operation', type: 'dropdown' },
{ id: 'domain', type: 'short-input' },
{ id: 'credential', type: 'oauth-input', mode: 'basic' },
{ id: 'issueKey', type: 'short-input', condition: { field: 'operation', value: 'read' } },
{ id: 'projectId', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'summary', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'description', type: 'long-input', condition: { field: 'operation', value: 'write' } },
{ id: 'priority', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'labels', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'issueType', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'parentIssue', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'assignee', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'reporter', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'environment', type: 'long-input', condition: { field: 'operation', value: 'write' } },
{ id: 'components', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'fixVersions', type: 'short-input', condition: { field: 'operation', value: 'write' } },
],
} as const
function createBlock(
id: string,
overrides: Partial<BlockState> = {},
@@ -39,7 +69,44 @@ function createWorkflowState({
}
}
function createJiraBlock(
id: string,
operation: 'read' | 'write',
overrides: Partial<BlockState> = {}
): BlockState {
return createBlock(id, {
type: 'jira',
position: { x: 100, y: 100 },
height: 100,
layout: { measuredWidth: 250, measuredHeight: 100 },
subBlocks: {
operation: {
id: 'operation',
type: 'dropdown',
value: operation,
},
domain: {
id: 'domain',
type: 'short-input',
value: 'company.atlassian.net',
},
credential: {
id: 'credential',
type: 'oauth-input',
value: 'credential-1',
},
},
...overrides,
})
}
describe('getTargetedLayoutChangeSet', () => {
beforeEach(() => {
mockGetBlock.mockImplementation((type: string) =>
type === 'jira' ? JIRA_TEST_BLOCK_CONFIG : undefined
)
})
it('does not relayout newly added blocks that already have valid positions', () => {
const before = createWorkflowState({
blocks: {
@@ -77,7 +144,15 @@ describe('getTargetedLayoutChangeSet', () => {
it('keeps subblock-only edits anchored', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start'),
start: createBlock('start', {
subBlocks: {
prompt: {
id: 'prompt',
type: 'long-input',
value: 'old value',
},
},
}),
},
})
@@ -98,10 +173,40 @@ describe('getTargetedLayoutChangeSet', () => {
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
})
it('reopens edited blocks when an operation change increases their visible height', () => {
const before = createWorkflowState({
blocks: {
jira: createJiraBlock('jira', 'read'),
},
})
const after = createWorkflowState({
blocks: {
jira: createJiraBlock('jira', 'write'),
},
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: [],
resizedBlockIds: ['jira'],
shiftSourceBlockIds: [],
})
})
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 } }),
start: createBlock('start', {
position: { x: 0, y: 0 },
subBlocks: {
prompt: {
id: 'prompt',
type: 'long-input',
value: 'old value',
},
},
}),
},
})
@@ -167,6 +272,7 @@ describe('getTargetedLayoutChangeSet', () => {
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: ['function1'],
resizedBlockIds: [],
shiftSourceBlockIds: [],
})
})
@@ -231,6 +337,7 @@ describe('getTargetedLayoutChangeSet', () => {
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: [],
resizedBlockIds: [],
shiftSourceBlockIds: ['source'],
})
})
@@ -279,6 +386,7 @@ describe('getTargetedLayoutChangeSet', () => {
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: [],
resizedBlockIds: [],
shiftSourceBlockIds: ['a-b'],
})
})
@@ -327,6 +435,7 @@ describe('getTargetedLayoutChangeSet', () => {
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: ['inserted'],
resizedBlockIds: [],
shiftSourceBlockIds: ['inserted'],
})
})

View File

@@ -1,4 +1,5 @@
import type { Edge } from 'reactflow'
import { getBlockMetrics } from '@/lib/workflows/autolayout/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
interface TargetedLayoutChangeSetOptions {
@@ -8,18 +9,21 @@ interface TargetedLayoutChangeSetOptions {
export interface TargetedLayoutImpact {
layoutBlockIds: string[]
resizedBlockIds: string[]
shiftSourceBlockIds: string[]
}
/**
* Computes the minimal structural change set that should be reopened for
* targeted layout after a workflow edit.
* Computes the minimal change set that should be reopened for targeted layout
* after a workflow edit. `layoutBlockIds` are fully repositioned, while
* `resizedBlockIds` keep their existing position and only shift neighbors.
*/
export function getTargetedLayoutImpact({
before,
after,
}: TargetedLayoutChangeSetOptions): TargetedLayoutImpact {
const layoutBlockIds = new Set<string>()
const resizedBlockIds = new Set<string>()
const afterBlockIds = new Set(Object.keys(after.blocks || {}))
const beforeBlockIds = new Set(Object.keys(before.blocks || {}))
@@ -40,6 +44,9 @@ export function getTargetedLayoutImpact({
const previousParentId = before.blocks[blockId]?.data?.parentId ?? null
const currentParentId = after.blocks[blockId]?.data?.parentId ?? null
if (previousParentId === currentParentId) {
if (hasLayoutRelevantSizeChange(before.blocks[blockId], after.blocks[blockId])) {
resizedBlockIds.add(blockId)
}
continue
}
@@ -57,6 +64,7 @@ export function getTargetedLayoutImpact({
if (addedEdges.length === 0) {
return {
layoutBlockIds: Array.from(layoutBlockIds),
resizedBlockIds: Array.from(resizedBlockIds),
shiftSourceBlockIds: [],
}
}
@@ -94,6 +102,7 @@ export function getTargetedLayoutImpact({
return {
layoutBlockIds: Array.from(layoutBlockIds),
resizedBlockIds: Array.from(resizedBlockIds),
shiftSourceBlockIds: Array.from(shiftSourceBlockIds),
}
}
@@ -123,6 +132,24 @@ function getBlocksWithInvalidPositions(
})
}
/**
* Returns true when a persisted block changed size enough that anchored layout
* should reopen its column and shift affected siblings.
*/
function hasLayoutRelevantSizeChange(
beforeBlock: WorkflowState['blocks'][string] | undefined,
afterBlock: WorkflowState['blocks'][string] | undefined
): boolean {
if (!beforeBlock || !afterBlock) {
return false
}
const beforeMetrics = getBlockMetrics(beforeBlock)
const afterMetrics = getBlockMetrics(afterBlock)
return beforeMetrics.height !== afterMetrics.height || beforeMetrics.width !== afterMetrics.width
}
/**
* Returns added edges that participate in layout within a shared parent scope.
*/

View File

@@ -1,12 +1,43 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_VERTICAL_SPACING } from '@/lib/workflows/autolayout/constants'
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'
const { mockGetBlock } = vi.hoisted(() => ({
mockGetBlock: vi.fn(),
}))
vi.mock('@/blocks', () => ({
getBlock: mockGetBlock,
}))
const JIRA_TEST_BLOCK_CONFIG = {
category: 'tools',
subBlocks: [
{ id: 'operation', type: 'dropdown' },
{ id: 'domain', type: 'short-input' },
{ id: 'credential', type: 'oauth-input', mode: 'basic' },
{ id: 'issueKey', type: 'short-input', condition: { field: 'operation', value: 'read' } },
{ id: 'projectId', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'summary', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'description', type: 'long-input', condition: { field: 'operation', value: 'write' } },
{ id: 'priority', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'labels', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'issueType', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'parentIssue', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'assignee', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'reporter', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'environment', type: 'long-input', condition: { field: 'operation', value: 'write' } },
{ id: 'components', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'fixVersions', type: 'short-input', condition: { field: 'operation', value: 'write' } },
],
} as const
function createBlock(id: string, overrides: Partial<BlockState> = {}): BlockState {
return {
id,
@@ -20,7 +51,51 @@ function createBlock(id: string, overrides: Partial<BlockState> = {}): BlockStat
}
}
function expectVerticalSeparation(upper: BlockState, lower: BlockState): void {
const upperMetrics = getBlockMetrics(upper)
expect(lower.position.y).toBeGreaterThanOrEqual(
upper.position.y + upperMetrics.height + DEFAULT_VERTICAL_SPACING
)
}
function createJiraBlock(
id: string,
operation: 'read' | 'write',
overrides: Partial<BlockState> = {}
): BlockState {
return createBlock(id, {
type: 'jira',
position: { x: 100, y: 100 },
height: 100,
layout: { measuredWidth: 250, measuredHeight: 100 },
subBlocks: {
operation: {
id: 'operation',
type: 'dropdown',
value: operation,
},
domain: {
id: 'domain',
type: 'short-input',
value: 'company.atlassian.net',
},
credential: {
id: 'credential',
type: 'oauth-input',
value: 'credential-1',
},
},
...overrides,
})
}
describe('applyTargetedLayout', () => {
beforeEach(() => {
mockGetBlock.mockImplementation((type: string) =>
type === 'jira' ? JIRA_TEST_BLOCK_CONFIG : undefined
)
})
it('shifts downstream frozen blocks when only shift sources are provided', () => {
const blocks = {
source: createBlock('source', {
@@ -118,6 +193,128 @@ describe('applyTargetedLayout', () => {
expect(result.changed.position.y).toBeLessThan(150)
})
it('pushes frozen blocks below downstream nodes shifted into occupied columns', () => {
const blocks = {
start: createBlock('start', {
position: { x: 100, y: 100 },
}),
inserted: createBlock('inserted', {
position: { x: 0, y: 0 },
}),
end: createBlock('end', {
position: { x: 400, y: 100 },
}),
branch: createBlock('branch', {
position: { x: 990, y: 150 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'start',
target: 'inserted',
},
{
id: 'edge-2',
source: 'inserted',
target: 'end',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: ['inserted'],
})
expect(result.end.position).toEqual({ x: 960, y: 100 })
expectVerticalSeparation(result.end, result.branch)
})
it('repairs vertical overlaps during shift-only targeted layout passes', () => {
const blocks = {
source: createBlock('source', {
position: { x: 100, y: 100 },
}),
target: createBlock('target', {
position: { x: 400, y: 100 },
}),
sibling: createBlock('sibling', {
position: { x: 560, y: 150 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'source',
target: 'target',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: [],
shiftSourceBlockIds: ['source'],
})
expect(result.target.position).toEqual({ x: 530, y: 100 })
expectVerticalSeparation(result.target, result.sibling)
})
it('resolves same-column overlaps even when another column is interleaved in Y order', () => {
const blocks = {
source: createBlock('source', {
position: { x: 100, y: 100 },
}),
target: createBlock('target', {
position: { x: 400, y: 100 },
}),
blocker: createBlock('blocker', {
position: { x: 1500, y: 140 },
}),
sibling: createBlock('sibling', {
position: { x: 560, y: 160 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'source',
target: 'target',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: [],
shiftSourceBlockIds: ['source'],
})
expect(result.blocker.position).toEqual({ x: 1500, y: 140 })
expect(result.target.position).toEqual({ x: 530, y: 100 })
expectVerticalSeparation(result.target, result.sibling)
})
it('keeps resized integration blocks anchored while shifting frozen blocks below them', () => {
const blocks = {
above: createBlock('above', {
position: { x: 430, y: 460 },
}),
jira: createJiraBlock('jira', 'write', {
position: { x: 433, y: 690 },
}),
below: createBlock('below', {
position: { x: 460, y: 1120 },
}),
}
const result = applyTargetedLayout(blocks, [], {
changedBlockIds: [],
resizedBlockIds: ['jira'],
})
expect(result.above.position).toEqual({ x: 430, y: 460 })
expect(result.jira.position).toEqual({ x: 433, y: 690 })
expect(getBlockMetrics(result.jira).height).toBeGreaterThan(100)
expectVerticalSeparation(result.jira, result.below)
})
it('places new parallel children below tall anchored siblings', () => {
const blocks = {
parallel: createBlock('parallel', {

View File

@@ -18,8 +18,16 @@ import {
import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import type { BlockState } from '@/stores/workflows/workflow/types'
type TargetedBlockInfo = {
id: string
block: BlockState
metrics: ReturnType<typeof getBlockMetrics>
}
export interface TargetedLayoutOptions extends LayoutOptions {
changedBlockIds: string[]
/** Existing blocks whose size changed but whose position should remain anchored. */
resizedBlockIds?: string[]
shiftSourceBlockIds?: string[]
verticalSpacing?: number
horizontalSpacing?: number
@@ -36,17 +44,23 @@ export function applyTargetedLayout(
): Record<string, BlockState> {
const {
changedBlockIds,
resizedBlockIds = [],
shiftSourceBlockIds = [],
verticalSpacing = DEFAULT_VERTICAL_SPACING,
horizontalSpacing = DEFAULT_HORIZONTAL_SPACING,
gridSize,
} = options
if ((!changedBlockIds || changedBlockIds.length === 0) && shiftSourceBlockIds.length === 0) {
if (
(!changedBlockIds || changedBlockIds.length === 0) &&
resizedBlockIds.length === 0 &&
shiftSourceBlockIds.length === 0
) {
return blocks
}
const changedSet = new Set(changedBlockIds)
const resizedSet = new Set(resizedBlockIds)
const shiftSourceSet = new Set(shiftSourceBlockIds)
const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks))
@@ -69,6 +83,7 @@ export function applyTargetedLayout(
blocksCopy,
edges,
changedSet,
resizedSet,
shiftSourceSet,
verticalSpacing,
horizontalSpacing,
@@ -83,6 +98,7 @@ export function applyTargetedLayout(
blocksCopy,
edges,
changedSet,
resizedSet,
shiftSourceSet,
verticalSpacing,
horizontalSpacing,
@@ -130,8 +146,9 @@ function selectBestAnchor(
/**
* Layouts a group of blocks (either root level or within a container).
* Only repositions blocks in `changedSet` or those with invalid positions;
* all other blocks act as anchors.
* Only repositions blocks in `changedSet` or those with invalid positions.
* Resized existing blocks remain anchored and instead drive shifts in nearby
* frozen blocks when their new dimensions create overlap.
*/
function layoutGroup(
parentId: string | null,
@@ -139,6 +156,7 @@ function layoutGroup(
blocks: Record<string, BlockState>,
edges: Edge[],
changedSet: Set<string>,
resizedSet: Set<string>,
shiftSourceSet: Set<string>,
verticalSpacing: number,
horizontalSpacing: number,
@@ -170,8 +188,13 @@ function layoutGroup(
})
const needsLayoutSet = new Set([...requestedLayout, ...invalidPositions])
const needsLayout = Array.from(needsLayoutSet)
const resizedAnchorIds = layoutEligibleChildIds.filter((id) => resizedSet.has(id))
const groupShiftSourceIds = layoutEligibleChildIds.filter((id) => shiftSourceSet.has(id))
const activeShiftSourceSet = new Set([...needsLayoutSet, ...groupShiftSourceIds])
const activeShiftSourceSet = new Set([
...needsLayoutSet,
...resizedAnchorIds,
...groupShiftSourceIds,
])
if (needsLayout.length === 0 && activeShiftSourceSet.size === 0) {
if (parentBlock) {
@@ -236,7 +259,7 @@ function layoutGroup(
}
}
shiftDownstreamFrozenBlocks(
const shiftedFrozenIds = shiftDownstreamFrozenBlocks(
activeShiftSourceSet,
needsLayoutSet,
layoutEligibleChildIds,
@@ -246,9 +269,10 @@ function layoutGroup(
gridSize
)
if (needsLayout.length > 0) {
const affectedBlockIds = new Set([...needsLayoutSet, ...resizedAnchorIds, ...shiftedFrozenIds])
if (affectedBlockIds.size > 0) {
resolveVerticalOverlapsWithFrozen(
needsLayoutSet,
affectedBlockIds,
layoutEligibleChildIds,
blocks,
verticalSpacing,
@@ -277,7 +301,7 @@ function shiftDownstreamFrozenBlocks(
edges: Edge[],
horizontalSpacing: number,
gridSize?: number
): void {
): Set<string> {
const eligibleSet = new Set(eligibleIds)
const downstreamMap = new Map<string, string[]>()
@@ -317,6 +341,8 @@ function shiftDownstreamFrozenBlocks(
}
}
}
return shifted
}
/**
@@ -327,7 +353,7 @@ function shiftDownstreamFrozenBlocks(
* through any further blocks below.
*/
function resolveVerticalOverlapsWithFrozen(
needsLayoutSet: Set<string>,
affectedBlockIds: Set<string>,
eligibleIds: string[],
blocks: Record<string, BlockState>,
verticalSpacing: number,
@@ -339,11 +365,11 @@ function resolveVerticalOverlapsWithFrozen(
if (!block) return null
return { id, block, metrics: getBlockMetrics(block) }
})
.filter((info): info is NonNullable<typeof info> => info !== null)
.filter((info): info is TargetedBlockInfo => info !== null)
if (blockInfos.length < 2) return
if (blockInfos.length < 2 || affectedBlockIds.size === 0) return
const movedSet = new Set(needsLayoutSet)
const movedSet = new Set(affectedBlockIds)
let hasOverlap = true
let iteration = 0
@@ -355,27 +381,48 @@ function resolveVerticalOverlapsWithFrozen(
for (let i = 0; i < blockInfos.length - 1; i++) {
const upper = blockInfos[i]
const lower = blockInfos[i + 1]
if (!movedSet.has(upper.id) && !movedSet.has(lower.id)) continue
for (let lowerIndex = i + 1; lowerIndex < blockInfos.length; lowerIndex++) {
const lower = blockInfos[lowerIndex]
const upperRight = upper.block.position.x + upper.metrics.width
const lowerRight = lower.block.position.x + lower.metrics.width
if (upper.block.position.x >= lowerRight || lower.block.position.x >= upperRight) continue
if (!movedSet.has(upper.id) && !movedSet.has(lower.id)) continue
if (!blocksOverlapOnX(upper, lower)) continue
const requiredY = upper.block.position.y + upper.metrics.height + verticalSpacing
if (lower.block.position.y >= requiredY) continue
const requiredY = upper.block.position.y + upper.metrics.height + verticalSpacing
if (lower.block.position.y < requiredY) {
lower.block.position = snapPositionToGrid(
{ x: lower.block.position.x, y: requiredY },
gridSize
)
movedSet.add(lower.id)
reorderBlockInfoByY(blockInfos, lowerIndex)
hasOverlap = true
}
}
}
}
function blocksOverlapOnX(left: TargetedBlockInfo, right: TargetedBlockInfo): boolean {
const leftRight = left.block.position.x + left.metrics.width
const rightRight = right.block.position.x + right.metrics.width
return left.block.position.x < rightRight && right.block.position.x < leftRight
}
function reorderBlockInfoByY(blockInfos: TargetedBlockInfo[], fromIndex: number): void {
const [movedInfo] = blockInfos.splice(fromIndex, 1)
let insertIndex = fromIndex
while (
insertIndex < blockInfos.length &&
blockInfos[insertIndex].block.position.y < movedInfo.block.position.y
) {
insertIndex++
}
blockInfos.splice(insertIndex, 0, movedInfo)
}
/**
* Computes layout positions for a subset of blocks using the core layout function
*/

View File

@@ -4,14 +4,23 @@ import {
CONTAINER_PADDING,
CONTAINER_PADDING_X,
CONTAINER_PADDING_Y,
ESTIMATED_BLOCK_BOTTOM_PADDING,
ESTIMATED_SUBBLOCK_HEIGHT,
MAX_ESTIMATED_BLOCK_HEIGHT,
ROOT_PADDING_X,
ROOT_PADDING_Y,
} from '@/lib/workflows/autolayout/constants'
import type { BlockMetrics, BoundingBox, Edge, GraphNode } from '@/lib/workflows/autolayout/types'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
import {
buildCanonicalIndex,
buildSubBlockValues,
type CanonicalModeOverrides,
evaluateSubBlockCondition,
isSubBlockFeatureEnabled,
isSubBlockHidden,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
@@ -134,24 +143,98 @@ function getContainerMetrics(block: BlockState): BlockMetrics {
}
/**
* Estimates block height from subblock count when no measurement is available.
* Only counts subblocks with non-null values to avoid over-counting conditional
* fields (e.g. agent blocks define ~20 subblocks but only ~5 are typically visible).
* The result is capped at MAX_ESTIMATED_BLOCK_HEIGHT to prevent massive layout gaps.
* Counts visible preview subblocks using the same visibility rules as the
* workflow block preview when no DOM measurements are available.
*/
function estimateBlockHeight(block: BlockState): number {
const subBlocks = block.subBlocks || {}
const visibleCount = Object.values(subBlocks).filter(
(sb) => sb && sb.value !== null && sb.value !== undefined
).length
if (visibleCount === 0) return BLOCK_DIMENSIONS.MIN_HEIGHT
function getVisiblePreviewSubBlockCount(block: BlockState): number {
const blockConfig = getBlock(block.type)
if (!blockConfig?.subBlocks?.length) {
return Object.values(block.subBlocks || {}).filter((subBlock) => subBlock != null).length
}
const estimated =
BLOCK_DIMENSIONS.HEADER_HEIGHT +
visibleCount * ESTIMATED_SUBBLOCK_HEIGHT +
ESTIMATED_BLOCK_BOTTOM_PADDING
const rawValues = buildSubBlockValues(block.subBlocks || {})
const canonicalModeOverrides =
typeof block.data?.canonicalModes === 'object' && block.data.canonicalModes !== null
? (block.data.canonicalModes as CanonicalModeOverrides)
: undefined
return Math.min(Math.max(estimated, BLOCK_DIMENSIONS.MIN_HEIGHT), MAX_ESTIMATED_BLOCK_HEIGHT)
if (canonicalModeOverrides) {
rawValues.__canonicalModes = canonicalModeOverrides
}
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
const effectiveAdvanced = Boolean(block.advancedMode)
const effectiveTrigger = Boolean(block.triggerMode)
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
return blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false
if (!isSubBlockFeatureEnabled(subBlock)) return false
if (isSubBlockHidden(subBlock)) return false
if (effectiveTrigger) {
const isValidTriggerSubblock = isPureTriggerBlock
? subBlock.mode === 'trigger' || !subBlock.mode
: subBlock.mode === 'trigger'
if (!isValidTriggerSubblock) {
return false
}
} else if (subBlock.mode === 'trigger') {
return false
}
if (
!isSubBlockVisibleForMode(
subBlock,
effectiveAdvanced,
canonicalIndex,
rawValues,
canonicalModeOverrides
)
) {
return false
}
if (!subBlock.condition) return true
return evaluateSubBlockCondition(subBlock.condition, rawValues)
}).length
}
/**
* Estimates workflow block dimensions using the same deterministic row-based
* formula used by the canvas block renderer.
*/
function estimateWorkflowBlockDimensions(block: BlockState): { width: number; height: number } {
const blockConfig = getBlock(block.type)
const visibleCount = getVisiblePreviewSubBlockCount(block)
if (!blockConfig) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
visibleCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT,
BLOCK_DIMENSIONS.MIN_HEIGHT
),
}
}
return calculateWorkflowBlockDimensions({
blockType: block.type,
category: blockConfig.category,
displayTriggerMode: Boolean(block.triggerMode),
visibleSubBlockCount: visibleCount,
conditionRowCount:
block.type === 'condition'
? getConditionRows(block.id, block.subBlocks?.conditions?.value).length
: 0,
routerRowCount:
block.type === 'router_v2'
? getRouterRows(block.id, block.subBlocks?.routes?.value).length
: 0,
})
}
/**
@@ -164,10 +247,12 @@ function getRegularBlockMetrics(block: BlockState): BlockMetrics {
const minHeight = BLOCK_DIMENSIONS.MIN_HEIGHT
const measuredH = block.layout?.measuredHeight ?? block.height
const measuredW = block.layout?.measuredWidth
const estimatedDimensions = estimateWorkflowBlockDimensions(block)
const estimatedHeight = estimatedDimensions.height
const hasMeasurement = typeof measuredH === 'number' && measuredH > 0
const height = hasMeasurement ? Math.max(measuredH, minHeight) : estimateBlockHeight(block)
const width = Math.max(measuredW ?? minWidth, minWidth)
const height = hasMeasurement ? Math.max(measuredH, estimatedHeight, minHeight) : estimatedHeight
const width = Math.max(measuredW ?? estimatedDimensions.width, minWidth)
return {
width,

View File

@@ -0,0 +1,47 @@
import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import type { BlockConfig } from '@/blocks/types'
interface WorkflowBlockDimensionsInput {
blockType: string
category: BlockConfig['category']
displayTriggerMode: boolean
visibleSubBlockCount: number
conditionRowCount?: number
routerRowCount?: number
}
export function calculateWorkflowBlockDimensions({
blockType,
category,
displayTriggerMode,
visibleSubBlockCount,
conditionRowCount = 0,
routerRowCount = 0,
}: WorkflowBlockDimensionsInput): { width: number; height: number } {
const shouldShowDefaultHandles =
category !== 'triggers' && blockType !== 'starter' && !displayTriggerMode
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
let rowsCount = 0
if (blockType === 'condition') {
rowsCount = conditionRowCount + defaultHandlesRow
} else if (blockType === 'router_v2') {
rowsCount = 1 + routerRowCount + defaultHandlesRow
} else {
rowsCount = visibleSubBlockCount + defaultHandlesRow
}
const hasContentBelowHeader = rowsCount > 0
const contentHeight = hasContentBelowHeader
? BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING + rowsCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
: 0
const height = Math.max(
BLOCK_DIMENSIONS.HEADER_HEIGHT + contentHeight,
BLOCK_DIMENSIONS.MIN_HEIGHT
)
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height,
}
}

View File

@@ -522,14 +522,18 @@ export class WorkflowDiffEngine {
// Apply autolayout to the proposed state
logger.info('Applying autolayout to proposed workflow state')
try {
const { layoutBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
before: mergedBaseline,
after: fullyCleanedState,
})
const totalBlocks = Object.keys(finalBlocks).length
if (layoutBlockIds.length === 0 && shiftSourceBlockIds.length === 0) {
if (
layoutBlockIds.length === 0 &&
resizedBlockIds.length === 0 &&
shiftSourceBlockIds.length === 0
) {
logger.info('No blocks need layout; skipping autolayout', {
totalBlocks,
})
@@ -541,6 +545,7 @@ export class WorkflowDiffEngine {
// as applyAutoLayout but with one unified code path.
logger.info('Using targeted layout for copilot edits', {
blocksNeedingLayout: layoutBlockIds.length,
resizedAnchorBlocks: resizedBlockIds.length,
shiftSourceBlocks: shiftSourceBlockIds.length,
anchors: totalBlocks - layoutBlockIds.length,
totalBlocks,
@@ -553,6 +558,7 @@ export class WorkflowDiffEngine {
const layoutedBlocks = applyTargetedLayout(finalBlocks, fullyCleanedState.edges, {
changedBlockIds: layoutBlockIds,
resizedBlockIds,
shiftSourceBlockIds,
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,