diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 8db6eabe3a..4fed68abfa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -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, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index fcfe183e62..5290361cb0 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -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 // 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, diff --git a/apps/sim/lib/workflows/autolayout/change-set.test.ts b/apps/sim/lib/workflows/autolayout/change-set.test.ts index e114349834..c3fac28aec 100644 --- a/apps/sim/lib/workflows/autolayout/change-set.test.ts +++ b/apps/sim/lib/workflows/autolayout/change-set.test.ts @@ -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 = {}, @@ -39,7 +69,44 @@ function createWorkflowState({ } } +function createJiraBlock( + id: string, + operation: 'read' | 'write', + overrides: Partial = {} +): 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'], }) }) diff --git a/apps/sim/lib/workflows/autolayout/change-set.ts b/apps/sim/lib/workflows/autolayout/change-set.ts index ce57517d90..ecf12598bc 100644 --- a/apps/sim/lib/workflows/autolayout/change-set.ts +++ b/apps/sim/lib/workflows/autolayout/change-set.ts @@ -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() + const resizedBlockIds = new Set() 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. */ diff --git a/apps/sim/lib/workflows/autolayout/targeted.test.ts b/apps/sim/lib/workflows/autolayout/targeted.test.ts index 7b7fdc4efd..117a07e703 100644 --- a/apps/sim/lib/workflows/autolayout/targeted.test.ts +++ b/apps/sim/lib/workflows/autolayout/targeted.test.ts @@ -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 { return { id, @@ -20,7 +51,51 @@ function createBlock(id: string, overrides: Partial = {}): 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 { + 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', { diff --git a/apps/sim/lib/workflows/autolayout/targeted.ts b/apps/sim/lib/workflows/autolayout/targeted.ts index f7d9e64306..33c0fb3f34 100644 --- a/apps/sim/lib/workflows/autolayout/targeted.ts +++ b/apps/sim/lib/workflows/autolayout/targeted.ts @@ -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 +} + 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 { 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 = 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, edges: Edge[], changedSet: Set, + resizedSet: Set, shiftSourceSet: Set, 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 { const eligibleSet = new Set(eligibleIds) const downstreamMap = new Map() @@ -317,6 +341,8 @@ function shiftDownstreamFrozenBlocks( } } } + + return shifted } /** @@ -327,7 +353,7 @@ function shiftDownstreamFrozenBlocks( * through any further blocks below. */ function resolveVerticalOverlapsWithFrozen( - needsLayoutSet: Set, + affectedBlockIds: Set, eligibleIds: string[], blocks: Record, verticalSpacing: number, @@ -339,11 +365,11 @@ function resolveVerticalOverlapsWithFrozen( if (!block) return null return { id, block, metrics: getBlockMetrics(block) } }) - .filter((info): info is NonNullable => 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 */ diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index ce03b76df8..ab42b48c9e 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -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, diff --git a/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts b/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts new file mode 100644 index 0000000000..24e236f733 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts @@ -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, + } +} diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 5093ac865f..6f270882c2 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -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,