From 9b61343f13bb293dbd26f75d57a6a68c094ace31 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:10:04 -0700 Subject: [PATCH 1/3] fix(workflow): attach cmdk-added blocks to the selected block --- apps/sim/AGENTS.md | 10 ++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 158 ++++++++++++++++-- 2 files changed, 158 insertions(+), 10 deletions(-) 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..f042d284aed 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. @@ -1850,6 +1861,130 @@ 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 + * currently selected block — or, with nothing selected, the canvas's + * only block (a fresh workflow's trigger; notes don't count), since + * attachment is unambiguous there. Otherwise no edge: there is no + * meaningful point to run proximity against — the placement point is the + * synthetic viewport centre — and guessing a source produced edges the + * user never implied. A source inside a container also yields no edge, + * since the new block lands at root level and the edge would cross the + * container boundary. + */ + const tryCreateEdgeForPositionlessAdd = useCallback( + (targetBlockId: string): Edge | undefined => { + if (!autoConnectRef.current) return undefined + + const selectedNodes = getNodes().filter((node) => node.selected) + let sourceId = selectedNodes[selectedNodes.length - 1]?.id + if (!sourceId) { + const flowBlocks = Object.values(blocks).filter( + (block) => !isAnnotationOnlyBlock(block.type) + ) + if (flowBlocks.length === 1) sourceId = flowBlocks[0].id + } + if (!sourceId) return undefined + + const source = blocks[sourceId] + if (!source || !isAutoConnectSourceCandidate(source)) return undefined + if (source.data?.parentId) return undefined + + const sourceHandle = determineSourceHandle({ id: sourceId, type: source.type }) + return createEdgeObject(sourceId, targetBlockId, sourceHandle) + }, + [blocks, getNodes, isAutoConnectSourceCandidate, determineSourceHandle, createEdgeObject] + ) + + /** + * Position for a block added with nothing selected: parked in the trigger + * column, below the blocks already there. Mirrors auto-layout, which + * assigns blocks with no incoming edges to layer 0 (the Start column), so + * hand-added unattached blocks stack exactly where a layout pass would + * put them. Returns null when no root-level block anchors the column. + */ + const getUnattachedBlockPosition = useCallback( + (blockType: string): { x: number; y: number } | null => { + const targetedIds = new Set(edges.map((edge) => edge.target)) + const layerZeroBlocks = Object.values(blocks).filter( + (block) => !block.data?.parentId && !targetedIds.has(block.id) + ) + if (layerZeroBlocks.length === 0) return null + + const anchor = layerZeroBlocks.reduce((topmost, block) => + block.position.y < topmost.position.y ? block : topmost + ) + + return nudgeBelowOccupiedSpots( + { + x: anchor.position.x, + y: anchor.position.y + getBlockDimensions(anchor.id).height + DEFAULT_VERTICAL_SPACING, + }, + estimateNewBlockDimensions(blockType) + ) + }, + [blocks, edges, getBlockDimensions, nudgeBelowOccupiedSpots] + ) + /** * Checks if adding a block would violate constraints (triggers or single-instance blocks) * and shows notification if so. @@ -2194,15 +2329,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) + 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 +2365,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) + const position = autoConnectEdge + ? getPositionAfterSourceBlock(autoConnectEdge.source, type) + : (getUnattachedBlockPosition(type) ?? basePosition) addBlock( id, type, name, - basePosition, + position, undefined, undefined, undefined, @@ -2263,9 +2400,10 @@ const WorkflowContent = React.memo( addBlock, effectivePermissions.canEdit, checkTriggerConstraints, - tryCreateAutoConnectEdge, - screenToFlowPosition, handleToolbarDrop, + tryCreateEdgeForPositionlessAdd, + getPositionAfterSourceBlock, + getUnattachedBlockPosition, ]) /** From 938d5055aaa5ca799c486622ec9e1cea142b14ff Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:49:17 -0700 Subject: [PATCH 2/3] improvement(workflow): fall back to last-touched block for cmdk adds, park unattached blocks in the rightmost column --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 59 +++++++++++-------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index f042d284aed..a3b46d79f6b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1658,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 @@ -1922,21 +1929,22 @@ const WorkflowContent = React.memo( /** * Edge for a positionless add (cmdk, toolbar click). The source is the - * currently selected block — or, with nothing selected, the canvas's - * only block (a fresh workflow's trigger; notes don't count), since - * attachment is unambiguous there. Otherwise no edge: there is no - * meaningful point to run proximity against — the placement point is the - * synthetic viewport centre — and guessing a source produced edges the - * user never implied. A source inside a container also yields no edge, - * since the new block lands at root level and the edge would cross the - * container boundary. + * currently selected block, falling back to the last block the user + * touched this session, then to the canvas's only block (a fresh + * workflow's trigger; notes don't count), since attachment is + * unambiguous there. Otherwise no edge: + * there is no meaningful point to run proximity against — the placement + * point is the synthetic viewport centre — and guessing a source + * produced edges the user never implied. A source inside a container + * also yields no edge, since the new block lands at root level and the + * edge would cross the container boundary. */ const tryCreateEdgeForPositionlessAdd = useCallback( (targetBlockId: string): Edge | undefined => { if (!autoConnectRef.current) return undefined const selectedNodes = getNodes().filter((node) => node.selected) - let sourceId = selectedNodes[selectedNodes.length - 1]?.id + let sourceId = selectedNodes[selectedNodes.length - 1]?.id ?? lastInteractedNodeId if (!sourceId) { const flowBlocks = Object.values(blocks).filter( (block) => !isAnnotationOnlyBlock(block.type) @@ -1952,26 +1960,32 @@ const WorkflowContent = React.memo( const sourceHandle = determineSourceHandle({ id: sourceId, type: source.type }) return createEdgeObject(sourceId, targetBlockId, sourceHandle) }, - [blocks, getNodes, isAutoConnectSourceCandidate, determineSourceHandle, createEdgeObject] + [ + blocks, + getNodes, + lastInteractedNodeId, + isAutoConnectSourceCandidate, + determineSourceHandle, + createEdgeObject, + ] ) /** - * Position for a block added with nothing selected: parked in the trigger - * column, below the blocks already there. Mirrors auto-layout, which - * assigns blocks with no incoming edges to layer 0 (the Start column), so - * hand-added unattached blocks stack exactly where a layout pass would - * put them. Returns null when no root-level block anchors the column. + * 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 targetedIds = new Set(edges.map((edge) => edge.target)) - const layerZeroBlocks = Object.values(blocks).filter( - (block) => !block.data?.parentId && !targetedIds.has(block.id) + const anchorCandidates = Object.values(blocks).filter( + (block) => !block.data?.parentId && !isAnnotationOnlyBlock(block.type) ) - if (layerZeroBlocks.length === 0) return null + if (anchorCandidates.length === 0) return null - const anchor = layerZeroBlocks.reduce((topmost, block) => - block.position.y < topmost.position.y ? block : topmost + const anchor = anchorCandidates.reduce((rightmost, block) => + block.position.x > rightmost.position.x ? block : rightmost ) return nudgeBelowOccupiedSpots( @@ -1982,7 +1996,7 @@ const WorkflowContent = React.memo( estimateNewBlockDimensions(blockType) ) }, - [blocks, edges, getBlockDimensions, nudgeBelowOccupiedSpots] + [blocks, getBlockDimensions, nudgeBelowOccupiedSpots] ) /** @@ -2939,7 +2953,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), From 1a6f6fe000fdc4b650b2a760fda4ca0c3186a415 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:02:29 -0700 Subject: [PATCH 3/3] fix(workflow): validate positionless-add sources for eligibility before choosing --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index a3b46d79f6b..9e05aa2b40d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1929,35 +1929,51 @@ const WorkflowContent = React.memo( /** * Edge for a positionless add (cmdk, toolbar click). The source is the - * currently selected block, falling back to the last block the user - * touched this session, then to the canvas's only block (a fresh - * workflow's trigger; notes don't count), since attachment is - * unambiguous there. Otherwise no edge: - * there is no meaningful point to run proximity against — the placement - * point is the synthetic viewport centre — and guessing a source - * produced edges the user never implied. A source inside a container - * also yields no edge, since the new block lands at root level and the - * edge would cross the container boundary. + * 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): Edge | undefined => { + (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 + } - const selectedNodes = getNodes().filter((node) => node.selected) - let sourceId = selectedNodes[selectedNodes.length - 1]?.id ?? lastInteractedNodeId if (!sourceId) { const flowBlocks = Object.values(blocks).filter( (block) => !isAnnotationOnlyBlock(block.type) ) - if (flowBlocks.length === 1) sourceId = flowBlocks[0].id + if (flowBlocks.length === 1 && isEligibleSource(flowBlocks[0].id)) { + sourceId = flowBlocks[0].id + } } - if (!sourceId) return undefined - const source = blocks[sourceId] - if (!source || !isAutoConnectSourceCandidate(source)) return undefined - if (source.data?.parentId) return undefined + if (!sourceId) return undefined - const sourceHandle = determineSourceHandle({ id: sourceId, type: source.type }) + const sourceHandle = determineSourceHandle({ id: sourceId, type: blocks[sourceId].type }) return createEdgeObject(sourceId, targetBlockId, sourceHandle) }, [ @@ -2343,7 +2359,7 @@ const WorkflowContent = React.memo( const baseName = type === 'loop' ? 'Loop' : 'Parallel' const name = getUniqueBlockName(baseName, blocks) - const autoConnectEdge = tryCreateEdgeForPositionlessAdd(id) + const autoConnectEdge = tryCreateEdgeForPositionlessAdd(id, type) const position = autoConnectEdge ? getPositionAfterSourceBlock(autoConnectEdge.source, type) : (getUnattachedBlockPosition(type) ?? basePosition) @@ -2379,7 +2395,7 @@ const WorkflowContent = React.memo( const baseName = defaultTriggerName || getDefaultBlockName(blockConfig) const name = getUniqueBlockName(baseName, blocks) - const autoConnectEdge = tryCreateEdgeForPositionlessAdd(id) + const autoConnectEdge = tryCreateEdgeForPositionlessAdd(id, type) const position = autoConnectEdge ? getPositionAfterSourceBlock(autoConnectEdge.source, type) : (getUnattachedBlockPosition(type) ?? basePosition)