diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index cbb8c9e113..6f5de152a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -6,6 +6,61 @@ import { getBlock } from '@/blocks/registry' const logger = createLogger('NodeUtilities') +/** + * Estimates block dimensions based on block type. + * Uses subblock count to estimate height for blocks that haven't been measured yet. + * + * @param blockType - The type of block (e.g., 'condition', 'agent') + * @returns Estimated width and height for the block + */ +export function estimateBlockDimensions(blockType: string): { width: number; height: number } { + const blockConfig = getBlock(blockType) + const subBlockCount = blockConfig?.subBlocks?.length ?? 3 + // Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.) + // Use roughly half the config count as a reasonable estimate, capped between 3-7 rows + const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7)) + const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0 + + const height = + BLOCK_DIMENSIONS.HEADER_HEIGHT + + BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING + + (estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT + + return { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT), + } +} + +/** + * Clamps a position to keep a block fully inside a container's content area. + * Content area starts after the header and padding, and ends before the right/bottom padding. + * + * @param position - Raw position relative to container origin + * @param containerDimensions - Container width and height + * @param blockDimensions - Block width and height + * @returns Clamped position that keeps block inside content area + */ +export function clampPositionToContainer( + position: { x: number; y: number }, + containerDimensions: { width: number; height: number }, + blockDimensions: { width: number; height: number } +): { x: number; y: number } { + const { width: containerWidth, height: containerHeight } = containerDimensions + const { width: blockWidth, height: blockHeight } = blockDimensions + + // Content area bounds (where blocks can be placed) + const minX = CONTAINER_DIMENSIONS.LEFT_PADDING + const minY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING + const maxX = containerWidth - CONTAINER_DIMENSIONS.RIGHT_PADDING - blockWidth + const maxY = containerHeight - CONTAINER_DIMENSIONS.BOTTOM_PADDING - blockHeight + + return { + x: Math.max(minX, Math.min(position.x, Math.max(minX, maxX))), + y: Math.max(minY, Math.min(position.y, Math.max(minY, maxY))), + } +} + /** * Hook providing utilities for node position, hierarchy, and dimension calculations */ @@ -21,7 +76,7 @@ export function useNodeUtilities(blocks: Record) { /** * Get the dimensions of a block. - * For regular blocks, estimates height based on block config if not yet measured. + * For regular blocks, uses stored height or estimates based on block config. */ const getBlockDimensions = useCallback( (blockId: string): { width: number; height: number } => { @@ -41,32 +96,16 @@ export function useNodeUtilities(blocks: Record) { } } - // Workflow block nodes have fixed visual width - const width = BLOCK_DIMENSIONS.FIXED_WIDTH - // Prefer deterministic height published by the block component; fallback to estimate - let height = block.height - - if (!height) { - // Estimate height based on block config's subblock count for more accurate initial sizing - // This is critical for subflow containers to size correctly before child blocks are measured - const blockConfig = getBlock(block.type) - const subBlockCount = blockConfig?.subBlocks?.length ?? 3 - // Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.) - // Use roughly half the config count as a reasonable estimate, capped between 3-7 rows - const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7)) - const hasErrorRow = block.type !== 'starter' && block.type !== 'response' ? 1 : 0 - - height = - BLOCK_DIMENSIONS.HEADER_HEIGHT + - BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING + - (estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT + if (block.height) { + return { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), + } } - return { - width, - height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT), - } + // Use shared estimation utility for blocks without measured height + return estimateBlockDimensions(block.type) }, [blocks, isContainerType] ) @@ -164,29 +203,36 @@ export function useNodeUtilities(blocks: Record) { ) /** - * Calculates the relative position of a node to a new parent's content area. - * Accounts for header height and padding offsets in container nodes. + * Calculates the relative position of a node to a new parent's origin. + * React Flow positions children relative to parent origin, so we clamp + * to the content area bounds (after header and padding). * @param nodeId ID of the node being repositioned * @param newParentId ID of the new parent - * @returns Relative position coordinates {x, y} within the parent's content area + * @returns Relative position coordinates {x, y} within the parent */ const calculateRelativePosition = useCallback( (nodeId: string, newParentId: string): { x: number; y: number } => { const nodeAbsPos = getNodeAbsolutePosition(nodeId) const parentAbsPos = getNodeAbsolutePosition(newParentId) + const parentNode = getNodes().find((n) => n.id === newParentId) - // Account for container's header and padding - // Children are positioned relative to content area, not container origin - const headerHeight = 50 - const leftPadding = 16 - const topPadding = 16 + // Calculate raw relative position (relative to parent origin) + const rawPosition = { + x: nodeAbsPos.x - parentAbsPos.x, + y: nodeAbsPos.y - parentAbsPos.y, + } - return { - x: nodeAbsPos.x - parentAbsPos.x - leftPadding, - y: nodeAbsPos.y - parentAbsPos.y - headerHeight - topPadding, + // Get container and block dimensions + const containerDimensions = { + width: parentNode?.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: parentNode?.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, } + const blockDimensions = getBlockDimensions(nodeId) + + // Clamp position to keep block inside content area + return clampPositionToContainer(rawPosition, containerDimensions, blockDimensions) }, - [getNodeAbsolutePosition] + [getNodeAbsolutePosition, getNodes, getBlockDimensions] ) /** @@ -252,7 +298,11 @@ export function useNodeUtilities(blocks: Record) { */ const calculateLoopDimensions = useCallback( (nodeId: string): { width: number; height: number } => { - const childNodes = getNodes().filter((node) => node.parentId === nodeId) + // Check both React Flow's node.parentId AND blocks store's data.parentId + // This ensures we catch children even if React Flow hasn't re-rendered yet + const childNodes = getNodes().filter( + (node) => node.parentId === nodeId || blocks[node.id]?.data?.parentId === nodeId + ) if (childNodes.length === 0) { return { width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, @@ -265,8 +315,11 @@ export function useNodeUtilities(blocks: Record) { childNodes.forEach((node) => { const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id) - maxRight = Math.max(maxRight, node.position.x + nodeWidth) - maxBottom = Math.max(maxBottom, node.position.y + nodeHeight) + // Use block position from store if available (more up-to-date) + const block = blocks[node.id] + const position = block?.position || node.position + maxRight = Math.max(maxRight, position.x + nodeWidth) + maxBottom = Math.max(maxBottom, position.y + nodeHeight) }) const width = Math.max( @@ -283,7 +336,7 @@ export function useNodeUtilities(blocks: Record) { return { width, height } }, - [getNodes, getBlockDimensions] + [getNodes, getBlockDimensions, blocks] ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index cb6ac6448d..42d3c40419 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -18,7 +18,7 @@ import { useShallow } from 'zustand/react/shallow' import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access' import { createLogger } from '@/lib/logs/console/logger' import type { OAuthProvider } from '@/lib/oauth' -import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -40,6 +40,10 @@ import { useCurrentWorkflow, useNodeUtilities, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' +import { + clampPositionToContainer, + estimateBlockDimensions, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' import { useSocket } from '@/app/workspace/providers/socket-provider' import { getBlock } from '@/blocks' import { isAnnotationOnlyBlock } from '@/executor/constants' @@ -694,17 +698,19 @@ const WorkflowContent = React.memo(() => { return } - // Calculate position relative to the container's content area - // Account for header (50px), left padding (16px), and top padding (16px) - const headerHeight = 50 - const leftPadding = 16 - const topPadding = 16 - - const relativePosition = { - x: position.x - containerInfo.loopPosition.x - leftPadding, - y: position.y - containerInfo.loopPosition.y - headerHeight - topPadding, + // Calculate raw position relative to container origin + const rawPosition = { + x: position.x - containerInfo.loopPosition.x, + y: position.y - containerInfo.loopPosition.y, } + // Clamp position to keep block inside container's content area + const relativePosition = clampPositionToContainer( + rawPosition, + containerInfo.dimensions, + estimateBlockDimensions(data.type) + ) + // Capture existing child blocks before adding the new one const existingChildBlocks = Object.values(blocks).filter( (b) => b.data?.parentId === containerInfo.loopId @@ -1910,17 +1916,47 @@ const WorkflowContent = React.memo(() => { }) document.body.style.cursor = '' + // Get the block's current parent (if any) + const currentBlock = blocks[node.id] + const currentParentId = currentBlock?.data?.parentId + + // Calculate position - clamp if inside a container + let finalPosition = node.position + if (currentParentId) { + // Block is inside a container - clamp position to keep it fully inside + const parentNode = getNodes().find((n) => n.id === currentParentId) + if (parentNode) { + const containerDimensions = { + width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + const blockDimensions = { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max( + currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT, + BLOCK_DIMENSIONS.MIN_HEIGHT + ), + } + + finalPosition = clampPositionToContainer( + node.position, + containerDimensions, + blockDimensions + ) + } + } + // Emit collaborative position update for the final position // This ensures other users see the smooth final position - collaborativeUpdateBlockPosition(node.id, node.position, true) + collaborativeUpdateBlockPosition(node.id, finalPosition, true) // Record single move entry on drag end to avoid micro-moves const start = getDragStartPosition() if (start && start.id === node.id) { const before = { x: start.x, y: start.y, parentId: start.parentId } const after = { - x: node.position.x, - y: node.position.y, + x: finalPosition.x, + y: finalPosition.y, parentId: node.parentId || blocks[node.id]?.data?.parentId, } const moved = diff --git a/apps/sim/tools/salesforce/create_account.ts b/apps/sim/tools/salesforce/create_account.ts index 08951d7342..d82b666503 100644 --- a/apps/sim/tools/salesforce/create_account.ts +++ b/apps/sim/tools/salesforce/create_account.ts @@ -3,7 +3,6 @@ import type { SalesforceCreateAccountParams, SalesforceCreateAccountResponse, } from '@/tools/salesforce/types' -import { getInstanceUrl } from '@/tools/salesforce/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SalesforceCreateAccount') diff --git a/apps/sim/tools/salesforce/delete_account.ts b/apps/sim/tools/salesforce/delete_account.ts index d2010fc57f..10e4007719 100644 --- a/apps/sim/tools/salesforce/delete_account.ts +++ b/apps/sim/tools/salesforce/delete_account.ts @@ -3,7 +3,6 @@ import type { SalesforceDeleteAccountParams, SalesforceDeleteAccountResponse, } from '@/tools/salesforce/types' -import { getInstanceUrl } from '@/tools/salesforce/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SalesforceDeleteAccount') diff --git a/apps/sim/tools/salesforce/get_accounts.ts b/apps/sim/tools/salesforce/get_accounts.ts index 718d3e027e..4180fb8fac 100644 --- a/apps/sim/tools/salesforce/get_accounts.ts +++ b/apps/sim/tools/salesforce/get_accounts.ts @@ -3,7 +3,6 @@ import type { SalesforceGetAccountsParams, SalesforceGetAccountsResponse, } from '@/tools/salesforce/types' -import { getInstanceUrl } from '@/tools/salesforce/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SalesforceGetAccounts') diff --git a/apps/sim/tools/salesforce/update_account.ts b/apps/sim/tools/salesforce/update_account.ts index 7927db59dc..0981db68b3 100644 --- a/apps/sim/tools/salesforce/update_account.ts +++ b/apps/sim/tools/salesforce/update_account.ts @@ -3,7 +3,6 @@ import type { SalesforceUpdateAccountParams, SalesforceUpdateAccountResponse, } from '@/tools/salesforce/types' -import { getInstanceUrl } from '@/tools/salesforce/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SalesforceUpdateAccount')