diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md index ded59456771..3e302f53509 100644 --- a/apps/sim/AGENTS.md +++ b/apps/sim/AGENTS.md @@ -243,3 +243,13 @@ export function useEntityList(workspaceId?: string) { - **Create `utils.ts` when** 2+ files need the same helper - **Check existing sources** before duplicating (`lib/` has many utilities) - **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use) + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 966b83b2072..9e05aa2b40d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -42,6 +42,10 @@ import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/base-to import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' import type { OAuthProvider } from '@/lib/oauth' import { OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' +import { + DEFAULT_HORIZONTAL_SPACING, + DEFAULT_VERTICAL_SPACING, +} from '@/lib/workflows/autolayout/constants' import { getDefaultBlockName } from '@/lib/workflows/blocks/canvas-presentation' import { requestNoteImage, requestNoteRename } from '@/lib/workflows/notes/canvas-requests' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' @@ -260,6 +264,13 @@ function syncPanelWithSelection(selectedIds: string[]) { } } +/** Footprint estimate for a new, not-yet-measured block of the given type. */ +function estimateNewBlockDimensions(blockType: string): { width: number; height: number } { + return blockType === 'loop' || blockType === 'parallel' + ? { width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT } + : estimateBlockDimensions(blockType) +} + /** * Map from edge contextId to edge id. * Context IDs include parent loop info for edges inside loops. @@ -1647,6 +1658,13 @@ const WorkflowContent = React.memo( [collaborativeBatchRemoveEdges] ) + /** + * The block the user touched most recently, retained after deselection + * and reset on reload. Drives z-ordering (the last touched card stays on + * top) and is the fallback source for positionless adds. + */ + const [lastInteractedNodeId, setLastInteractedNodeId] = useState(null) + const isAutoConnectSourceCandidate = useCallback((block: BlockState): boolean => { if (!block.enabled) return false if (block.type === 'response') return false @@ -1850,6 +1868,153 @@ const WorkflowContent = React.memo( ] ) + /** + * Drops a proposed spot below any root-level block already occupying it, + * cascading in top-to-bottom order so repeated adds stack instead of pile. + */ + const nudgeBelowOccupiedSpots = useCallback( + ( + start: { x: number; y: number }, + dimensions: { width: number; height: number } + ): { x: number; y: number } => { + const occupants = Object.values(blocks) + .filter((block) => !block.data?.parentId) + .map((block) => { + const blockDimensions = getBlockDimensions(block.id) + return { + left: block.position.x, + right: block.position.x + blockDimensions.width, + top: block.position.y, + bottom: block.position.y + blockDimensions.height, + } + }) + .sort((a, b) => a.top - b.top) + + let y = start.y + for (const rect of occupants) { + const overlapsX = start.x < rect.right && start.x + dimensions.width > rect.left + const overlapsY = y < rect.bottom && y + dimensions.height > rect.top + if (overlapsX && overlapsY) { + y = rect.bottom + DEFAULT_VERTICAL_SPACING + } + } + + return { x: start.x, y } + }, + [blocks, getBlockDimensions] + ) + + /** + * Positions a block added without an explicit drop point after its + * auto-connect source: one layout column to the right, vertically centred + * on the source, then nudged below any root-level block already occupying + * that spot (a fan-out from a source that already has a next block). + */ + const getPositionAfterSourceBlock = useCallback( + (sourceBlockId: string, blockType: string): { x: number; y: number } => { + const sourcePosition = getNodeAbsolutePosition(sourceBlockId) + const sourceDimensions = getBlockDimensions(sourceBlockId) + const newBlockDimensions = estimateNewBlockDimensions(blockType) + + return nudgeBelowOccupiedSpots( + { + x: sourcePosition.x + sourceDimensions.width + DEFAULT_HORIZONTAL_SPACING, + y: sourcePosition.y + sourceDimensions.height / 2 - newBlockDimensions.height / 2, + }, + newBlockDimensions + ) + }, + [getBlockDimensions, getNodeAbsolutePosition, nudgeBelowOccupiedSpots] + ) + + /** + * Edge for a positionless add (cmdk, toolbar click). The source is the + * rightmost eligible selected block — multi-selects carry no click + * order, so the visual end of the selection is the deterministic pick — + * falling back to the last block the user touched this session, then to + * the canvas's only flow block (a fresh workflow's trigger), where + * attachment is unambiguous. Ineligible candidates (notes, disabled and + * response blocks, container children — the new block lands at root + * level, so that edge would cross the container boundary) fall through + * to the next signal rather than blocking attachment, and annotations + * never take an edge as target. With no eligible source there is no + * edge: guessing one produced edges the user never implied. + */ + const tryCreateEdgeForPositionlessAdd = useCallback( + (targetBlockId: string, targetBlockType: string): Edge | undefined => { + if (!autoConnectRef.current) return undefined + if (isAnnotationOnlyBlock(targetBlockType)) return undefined + + const isEligibleSource = (blockId: string): boolean => { + const block = blocks[blockId] + return !!block && isAutoConnectSourceCandidate(block) && !block.data?.parentId + } + + let sourceId: string | null = null + for (const node of getNodes()) { + if (!node.selected || !isEligibleSource(node.id)) continue + if (!sourceId || blocks[node.id].position.x > blocks[sourceId].position.x) { + sourceId = node.id + } + } + + if (!sourceId && lastInteractedNodeId && isEligibleSource(lastInteractedNodeId)) { + sourceId = lastInteractedNodeId + } + + if (!sourceId) { + const flowBlocks = Object.values(blocks).filter( + (block) => !isAnnotationOnlyBlock(block.type) + ) + if (flowBlocks.length === 1 && isEligibleSource(flowBlocks[0].id)) { + sourceId = flowBlocks[0].id + } + } + + if (!sourceId) return undefined + + const sourceHandle = determineSourceHandle({ id: sourceId, type: blocks[sourceId].type }) + return createEdgeObject(sourceId, targetBlockId, sourceHandle) + }, + [ + blocks, + getNodes, + lastInteractedNodeId, + isAutoConnectSourceCandidate, + determineSourceHandle, + createEdgeObject, + ] + ) + + /** + * Position for a block added unattached: parked at the bottom of the + * rightmost column of root-level flow blocks — near the end of the + * workflow, where the user is most likely to wire it in. Notes don't + * anchor the column. Returns null when the canvas has no root-level flow + * block to anchor on. + */ + const getUnattachedBlockPosition = useCallback( + (blockType: string): { x: number; y: number } | null => { + const anchorCandidates = Object.values(blocks).filter( + (block) => !block.data?.parentId && !isAnnotationOnlyBlock(block.type) + ) + if (anchorCandidates.length === 0) return null + + const anchor = anchorCandidates.reduce((rightmost, block) => + block.position.x > rightmost.position.x ? block : rightmost + ) + + return nudgeBelowOccupiedSpots( + { + x: anchor.position.x, + y: anchor.position.y + getBlockDimensions(anchor.id).height + DEFAULT_VERTICAL_SPACING, + }, + estimateNewBlockDimensions(blockType) + ) + }, + [blocks, getBlockDimensions, nudgeBelowOccupiedSpots] + ) + /** * Checks if adding a block would violate constraints (triggers or single-instance blocks) * and shows notification if so. @@ -2194,15 +2359,16 @@ const WorkflowContent = React.memo( const baseName = type === 'loop' ? 'Loop' : 'Parallel' const name = getUniqueBlockName(baseName, blocks) - const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, { - targetParentId: null, - }) + const autoConnectEdge = tryCreateEdgeForPositionlessAdd(id, type) + const position = autoConnectEdge + ? getPositionAfterSourceBlock(autoConnectEdge.source, type) + : (getUnattachedBlockPosition(type) ?? basePosition) addBlock( id, type, name, - basePosition, + position, { width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, @@ -2229,15 +2395,16 @@ const WorkflowContent = React.memo( const baseName = defaultTriggerName || getDefaultBlockName(blockConfig) const name = getUniqueBlockName(baseName, blocks) - const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, { - targetParentId: null, - }) + const autoConnectEdge = tryCreateEdgeForPositionlessAdd(id, type) + const position = autoConnectEdge + ? getPositionAfterSourceBlock(autoConnectEdge.source, type) + : (getUnattachedBlockPosition(type) ?? basePosition) addBlock( id, type, name, - basePosition, + position, undefined, undefined, undefined, @@ -2263,9 +2430,10 @@ const WorkflowContent = React.memo( addBlock, effectivePermissions.canEdit, checkTriggerConstraints, - tryCreateAutoConnectEdge, - screenToFlowPosition, handleToolbarDrop, + tryCreateEdgeForPositionlessAdd, + getPositionAfterSourceBlock, + getUnattachedBlockPosition, ]) /** @@ -2801,7 +2969,6 @@ const WorkflowContent = React.memo( // Local state for nodes - allows smooth drag without store updates on every frame const [displayNodes, setDisplayNodes] = useState([]) - const [lastInteractedNodeId, setLastInteractedNodeId] = useState(null) const selectedNodeIds = useMemo( () => displayNodes.filter((node) => node.selected).map((node) => node.id),