From 31ae9f8d7f3cab8170de852409d8008635faf42d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 18:49:00 -0700 Subject: [PATCH 1/6] feat(workflow-editor): open block palette on edge drag-release with auto-connect --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 77 +++++++- .../components/search-modal/search-modal.tsx | 176 +++++++++++------- apps/sim/stores/modals/search/store.ts | 9 +- apps/sim/stores/modals/search/types.ts | 39 +++- 4 files changed, 216 insertions(+), 85 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 4ab924e8f3d..c6d2f49f002 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -529,6 +529,18 @@ const WorkflowContent = React.memo( /** Tracks whether onConnect successfully handled the connection (ReactFlow pattern). */ const connectionCompletedRef = useRef(false) + /** + * Holds the drag origin + drop point when a connection is released on empty + * canvas and the command palette is opened. The block chosen from the palette + * is placed at the drop point and wired from this source handle. Cleared when + * the palette closes without a selection. + */ + const pendingConnectRef = useRef<{ + source: { nodeId: string; handleId: string } + screenX: number + screenY: number + } | null>(null) + /** Stores start positions for multi-node drag undo/redo recording. */ const multiNodeDragStartRef = useRef>( new Map() @@ -1977,16 +1989,39 @@ const WorkflowContent = React.memo( if (typeof type !== 'string' || !type) return if (type === 'connectionBlock') return - const basePosition = getViewportCenter() + // Consume any pending drag-release: it dictates the drop position and a + // forced edge from the released source handle, overriding auto-connect. + const pending = pendingConnectRef.current + pendingConnectRef.current = null + const pendingSourceExists = pending ? Boolean(blocks[pending.source.nodeId]) : false + + let basePosition = getViewportCenter() + if (pending) { + const canvasElement = document.querySelector('.workflow-container') as HTMLElement | null + if (canvasElement) { + const bounds = canvasElement.getBoundingClientRect() + basePosition = screenToFlowPosition({ + x: pending.screenX - bounds.left, + y: pending.screenY - bounds.top, + }) + } + } + + /** + * Edge for the new block: forced from the drag source when the palette was + * opened by a connection release, otherwise the normal auto-connect edge. + */ + const resolveAutoConnectEdge = (targetId: string): Edge | undefined => + pending && pendingSourceExists + ? createEdgeObject(pending.source.nodeId, targetId, pending.source.handleId) + : tryCreateAutoConnectEdge(basePosition, targetId, { targetParentId: null }) if (type === 'loop' || type === 'parallel') { const id = generateId() const baseName = type === 'loop' ? 'Loop' : 'Parallel' const name = getUniqueBlockName(baseName, blocks) - const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, { - targetParentId: null, - }) + const autoConnectEdge = resolveAutoConnectEdge(id) addBlock( id, @@ -2019,9 +2054,7 @@ const WorkflowContent = React.memo( const baseName = defaultTriggerName || blockConfig.name const name = getUniqueBlockName(baseName, blocks) - const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, { - targetParentId: null, - }) + const autoConnectEdge = resolveAutoConnectEdge(id) addBlock( id, @@ -2054,8 +2087,26 @@ const WorkflowContent = React.memo( effectivePermissions.canEdit, checkTriggerConstraints, tryCreateAutoConnectEdge, + screenToFlowPosition, + createEdgeObject, ]) + /** + * Drops a pending drag-release connection if the command palette is dismissed + * without picking a block. A real selection consumes the ref synchronously (via + * the `add-block-from-toolbar` event) before the modal closes, so a ref still + * set on close means the user cancelled. + */ + useEffect( + () => + useSearchModalStore.subscribe((state, prev) => { + if (prev.isOpen && !state.isOpen) { + pendingConnectRef.current = null + } + }), + [] + ) + /** * Listen for toolbar drops that occur on the empty-workflow overlay (command list). * @@ -3132,6 +3183,18 @@ const WorkflowContent = React.memo( target: targetNode.id, targetHandle: 'target', }) + } else if (!targetNode) { + // Released on empty canvas: open the command palette and remember the + // drag origin + drop point so the chosen block lands here, wired from + // this source handle. + pendingConnectRef.current = { + source: { nodeId: source.nodeId, handleId: source.handleId }, + screenX: clientPos.clientX, + screenY: clientPos.clientY, + } + useSearchModalStore.getState().open({ + sections: ['blocks', 'tools', 'toolOperations'], + }) } connectionSourceRef.current = null diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 1c5b4fd32bc..e6268e15d95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -40,6 +40,7 @@ import { useSearchModalStore } from '@/stores/modals/search/store' import type { SearchBlockItem, SearchDocItem, + SearchSection, SearchToolOperationItem, } from '@/stores/modals/search/types' import { @@ -118,6 +119,9 @@ export function SearchModal({ (state) => state.data ) + const sections = useSearchModalStore((state) => state.sections) + const showSection = (key: SearchSection) => !sections || sections.includes(key) + const openHelpModal = useCallback(() => { window.dispatchEvent(new CustomEvent('open-help-modal')) }, []) @@ -690,77 +694,107 @@ export function SearchModal({ No results found. - - - - - - - - - - - - - - - + {showSection('actions') && ( + + )} + {showSection('connectedAccounts') && ( + + )} + {showSection('integrations') && ( + + )} + {showSection('blocks') && ( + + )} + {showSection('tools') && ( + + )} + {showSection('triggers') && ( + + )} + {showSection('chats') && ( + + )} + {showSection('workflows') && ( + + )} + {showSection('tables') && ( + + )} + {showSection('files') && ( + + )} + {showSection('knowledgeBases') && ( + + )} + {showSection('toolOperations') && ( + + )} + {showSection('workspaces') && ( + + )} + {showSection('docs') && ( + + )} + {showSection('pages') && ( + + )} diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index 10808b860b0..99c4071575c 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -67,18 +67,19 @@ export const useSearchModalStore = create()( devtools( (set, _) => ({ isOpen: false, + sections: null, data: initialData, setOpen: (open: boolean) => { - set({ isOpen: open }) + set({ isOpen: open, sections: null }) }, - open: () => { - set({ isOpen: true }) + open: (options) => { + set({ isOpen: true, sections: options?.sections ?? null }) }, close: () => { - set({ isOpen: false }) + set({ isOpen: false, sections: null }) }, initializeData: (filterBlocks) => { diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index b276f23627a..cb5e73c716a 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -49,6 +49,32 @@ export interface SearchData { isInitialized: boolean } +/** + * Every result group the search modal can render, in render order. Used to + * restrict the palette to a subset of sections when opened for a specific + * intent (e.g. a drag-release that should only offer canvas-insertable items). + */ +export const SEARCH_SECTIONS = [ + 'actions', + 'connectedAccounts', + 'integrations', + 'blocks', + 'tools', + 'triggers', + 'chats', + 'workflows', + 'tables', + 'files', + 'knowledgeBases', + 'toolOperations', + 'workspaces', + 'docs', + 'pages', +] as const + +/** A single search-modal result group. */ +export type SearchSection = (typeof SEARCH_SECTIONS)[number] + /** * Global state for the universal search modal. * @@ -60,18 +86,25 @@ export interface SearchModalState { /** Whether the search modal is currently open. */ isOpen: boolean + /** + * When set, the palette renders only these sections; `null` shows all of them. + */ + sections: SearchSection[] | null + /** Pre-computed search data. */ data: SearchData /** - * Explicitly set the open state of the modal. + * Explicitly set the open state of the modal. Always resets to the full + * palette (no section restriction). */ setOpen: (open: boolean) => void /** - * Convenience method to open the modal. + * Convenience method to open the modal. Pass `sections` to restrict the + * palette to a subset of result groups. */ - open: () => void + open: (options?: { sections?: SearchSection[] }) => void /** * Convenience method to close the modal. From 8da6b272261da630eca1a7881e4ff0d9451215a1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 19:15:49 -0700 Subject: [PATCH 2/6] fix(workflow-editor): correct drag-release drop coords, scoping, and container placement --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index c6d2f49f002..4cf5c3b64aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1989,30 +1989,46 @@ const WorkflowContent = React.memo( if (typeof type !== 'string' || !type) return if (type === 'connectionBlock') return - // Consume any pending drag-release: it dictates the drop position and a - // forced edge from the released source handle, overriding auto-connect. - const pending = pendingConnectRef.current + // Consume a pending drag-release only while the restricted connect-palette + // it opened is still up — never let an unrelated add-block event (toolbar, + // sidebar, command list) inherit its position/source. + const searchModal = useSearchModalStore.getState() + const pending = + searchModal.isOpen && searchModal.sections ? pendingConnectRef.current : null pendingConnectRef.current = null - const pendingSourceExists = pending ? Boolean(blocks[pending.source.nodeId]) : false let basePosition = getViewportCenter() if (pending) { - const canvasElement = document.querySelector('.workflow-container') as HTMLElement | null - if (canvasElement) { - const bounds = canvasElement.getBoundingClientRect() - basePosition = screenToFlowPosition({ - x: pending.screenX - bounds.left, - y: pending.screenY - bounds.top, - }) + // screenToFlowPosition already subtracts the pane rect internally — pass + // raw client coords, do NOT pre-subtract container bounds. + basePosition = screenToFlowPosition({ x: pending.screenX, y: pending.screenY }) + + // Released inside a loop/parallel container: delegate to the container-aware + // drop path for correct parenting, clamping, and in-container auto-connect. + // A forced source→target edge would cross the container boundary, so it is + // intentionally not applied here. + if (isPointInLoopNode(basePosition)) { + handleToolbarDrop({ type, enableTriggerMode: enableTriggerMode === true }, basePosition) + return } } + // Force the source→new-block edge only when it stays root-to-root, matching + // the root-level placement — a source inside a container (or a container-start + // handle) would cross the boundary and is rejected, as in onConnect. + const sourceBlock = pending ? blocks[pending.source.nodeId] : undefined + const isContainerStartHandle = + pending?.source.handleId === 'loop-start-source' || + pending?.source.handleId === 'parallel-start-source' + const canForceEdge = + Boolean(sourceBlock) && !sourceBlock?.data?.parentId && !isContainerStartHandle + /** * Edge for the new block: forced from the drag source when the palette was * opened by a connection release, otherwise the normal auto-connect edge. */ const resolveAutoConnectEdge = (targetId: string): Edge | undefined => - pending && pendingSourceExists + pending && canForceEdge ? createEdgeObject(pending.source.nodeId, targetId, pending.source.handleId) : tryCreateAutoConnectEdge(basePosition, targetId, { targetParentId: null }) @@ -2089,6 +2105,8 @@ const WorkflowContent = React.memo( tryCreateAutoConnectEdge, screenToFlowPosition, createEdgeObject, + handleToolbarDrop, + isPointInLoopNode, ]) /** From 7a5f66b4922641749fa4132369291cd8e09b4896 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 19:24:41 -0700 Subject: [PATCH 3/6] fix(workflow-editor): correlate drag-release palette selection with a token --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 22 +++++++++++++------ .../components/search-modal/search-modal.tsx | 7 +++++- apps/sim/stores/modals/search/store.ts | 11 +++++++--- apps/sim/stores/modals/search/types.ts | 16 +++++++++++--- 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 4cf5c3b64aa..baae098d5a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -209,6 +209,7 @@ interface AddBlockFromToolbarDetail { type?: unknown enableTriggerMode?: unknown presetOperation?: unknown + connectToken?: unknown } /** @@ -539,6 +540,7 @@ const WorkflowContent = React.memo( source: { nodeId: string; handleId: string } screenX: number screenY: number + token: string } | null>(null) /** Stores start positions for multi-node drag undo/redo recording. */ @@ -1984,18 +1986,21 @@ const WorkflowContent = React.memo( return } - const { type, enableTriggerMode, presetOperation } = event.detail + const { type, enableTriggerMode, presetOperation, connectToken } = event.detail if (typeof type !== 'string' || !type) return if (type === 'connectionBlock') return - // Consume a pending drag-release only while the restricted connect-palette - // it opened is still up — never let an unrelated add-block event (toolbar, - // sidebar, command list) inherit its position/source. - const searchModal = useSearchModalStore.getState() + // Consume a pending drag-release only when THIS event is the palette + // selection it opened, matched by correlation token. Any other + // add-block event (toolbar, sidebar, command list) carries no matching + // token and cannot inherit the pending position/source. + const pendingRef = pendingConnectRef.current const pending = - searchModal.isOpen && searchModal.sections ? pendingConnectRef.current : null - pendingConnectRef.current = null + pendingRef && typeof connectToken === 'string' && connectToken === pendingRef.token + ? pendingRef + : null + if (pending) pendingConnectRef.current = null let basePosition = getViewportCenter() if (pending) { @@ -3205,13 +3210,16 @@ const WorkflowContent = React.memo( // Released on empty canvas: open the command palette and remember the // drag origin + drop point so the chosen block lands here, wired from // this source handle. + const connectToken = generateId() pendingConnectRef.current = { source: { nodeId: source.nodeId, handleId: source.handleId }, screenX: clientPos.clientX, screenY: clientPos.clientY, + token: connectToken, } useSearchModalStore.getState().open({ sections: ['blocks', 'tools', 'toolOperations'], + connectToken, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index e6268e15d95..5c4b2c54a1c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -351,6 +351,7 @@ export function SearchModal({ detail: { type: block.type, enableTriggerMode, + connectToken: useSearchModalStore.getState().connectToken, }, }) ) @@ -368,7 +369,11 @@ export function SearchModal({ (op: SearchToolOperationItem) => { window.dispatchEvent( new CustomEvent('add-block-from-toolbar', { - detail: { type: op.blockType, presetOperation: op.operationId }, + detail: { + type: op.blockType, + presetOperation: op.operationId, + connectToken: useSearchModalStore.getState().connectToken, + }, }) ) captureEvent(posthogRef.current, 'search_result_selected', { diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index 99c4071575c..fb73bfff850 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -68,18 +68,23 @@ export const useSearchModalStore = create()( (set, _) => ({ isOpen: false, sections: null, + connectToken: null, data: initialData, setOpen: (open: boolean) => { - set({ isOpen: open, sections: null }) + set({ isOpen: open, sections: null, connectToken: null }) }, open: (options) => { - set({ isOpen: true, sections: options?.sections ?? null }) + set({ + isOpen: true, + sections: options?.sections ?? null, + connectToken: options?.connectToken ?? null, + }) }, close: () => { - set({ isOpen: false, sections: null }) + set({ isOpen: false, sections: null, connectToken: null }) }, initializeData: (filterBlocks) => { diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index cb5e73c716a..eec30feec5f 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -91,20 +91,30 @@ export interface SearchModalState { */ sections: SearchSection[] | null + /** + * Correlation token for the open session. When the palette is opened to + * complete a specific action (e.g. an edge drag-release), the opener passes a + * token that a selection stamps onto its event, so only that selection — not + * an unrelated concurrent add — can consume the pending action. `null` for + * ordinary opens. + */ + connectToken: string | null + /** Pre-computed search data. */ data: SearchData /** * Explicitly set the open state of the modal. Always resets to the full - * palette (no section restriction). + * palette (no section restriction, no correlation token). */ setOpen: (open: boolean) => void /** * Convenience method to open the modal. Pass `sections` to restrict the - * palette to a subset of result groups. + * palette to a subset of result groups, and `connectToken` to correlate a + * selection with a pending action. */ - open: (options?: { sections?: SearchSection[] }) => void + open: (options?: { sections?: SearchSection[]; connectToken?: string }) => void /** * Convenience method to close the modal. From 2d5ce1e5ca1c604afdd0f034469c8e883ebf90d5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 19:31:57 -0700 Subject: [PATCH 4/6] fix(workflow-editor): preserve tool operation preset on in-container drag-release --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index baae098d5a9..75400e4e0ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1793,9 +1793,16 @@ const WorkflowContent = React.memo( * @param position - Drop position in ReactFlow coordinates. */ const handleToolbarDrop = useCallback( - (data: { type: string; enableTriggerMode?: boolean }, position: { x: number; y: number }) => { + ( + data: { type: string; enableTriggerMode?: boolean; presetOperation?: string }, + position: { x: number; y: number } + ) => { if (!data.type || data.type === 'connectionBlock') return + const operationConfig = data.presetOperation + ? { operation: data.presetOperation } + : undefined + try { const containerInfo = isPointInLoopNode(position) @@ -1935,7 +1942,9 @@ const WorkflowContent = React.memo( }, containerInfo.loopId, 'parent', - autoConnectEdge + autoConnectEdge, + undefined, + operationConfig ) // Resize the container node to fit the new block @@ -1961,7 +1970,8 @@ const WorkflowContent = React.memo( undefined, undefined, autoConnectEdge, - enableTriggerMode + enableTriggerMode, + operationConfig ) } } catch (err) { @@ -2013,7 +2023,14 @@ const WorkflowContent = React.memo( // A forced source→target edge would cross the container boundary, so it is // intentionally not applied here. if (isPointInLoopNode(basePosition)) { - handleToolbarDrop({ type, enableTriggerMode: enableTriggerMode === true }, basePosition) + handleToolbarDrop( + { + type, + enableTriggerMode: enableTriggerMode === true, + presetOperation: typeof presetOperation === 'string' ? presetOperation : undefined, + }, + basePosition + ) return } } From 40347c470fe28396c340dd792c2ea1530c500a68 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 19:42:39 -0700 Subject: [PATCH 5/6] fix(workflow-editor): wire drag-release edge from the actual source handle via handleToolbarDrop --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 161 ++++++++++-------- 1 file changed, 91 insertions(+), 70 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 75400e4e0ab..ba4de8a2053 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1794,7 +1794,12 @@ const WorkflowContent = React.memo( */ const handleToolbarDrop = useCallback( ( - data: { type: string; enableTriggerMode?: boolean; presetOperation?: string }, + data: { + type: string + enableTriggerMode?: boolean + presetOperation?: string + forcedSource?: { nodeId: string; handleId: string } + }, position: { x: number; y: number } ) => { if (!data.type || data.type === 'connectionBlock') return @@ -1803,6 +1808,40 @@ const WorkflowContent = React.memo( ? { operation: data.presetOperation } : undefined + const { forcedSource } = data + + /** + * Edge for the new block. With a `forcedSource` (a drag-release from a + * handle), wire from that exact handle — but only when it stays within the + * resolved container context, matching onConnect's boundary rules; a + * cross-boundary source yields no edge. Otherwise fall back to normal + * proximity auto-connect. + */ + const resolveEdge = ( + targetId: string, + targetParentId: string | null, + fallback: () => Edge | undefined + ): Edge | undefined => { + if (!forcedSource) return fallback() + + const isContainerStartHandle = + forcedSource.handleId === 'loop-start-source' || + forcedSource.handleId === 'parallel-start-source' + if (isContainerStartHandle) { + // A container-start handle may only wire to a child of that container. + return forcedSource.nodeId === targetParentId + ? createEdgeObject(forcedSource.nodeId, targetId, forcedSource.handleId) + : undefined + } + + const sourceBlock = blocks[forcedSource.nodeId] + if (!sourceBlock) return undefined + const sourceParentId = sourceBlock.data?.parentId ?? null + return sourceParentId === targetParentId + ? createEdgeObject(forcedSource.nodeId, targetId, forcedSource.handleId) + : undefined + } + try { const containerInfo = isPointInLoopNode(position) @@ -1832,11 +1871,13 @@ const WorkflowContent = React.memo( .filter((b) => b.data?.parentId === containerInfo.loopId) .map((b) => ({ id: b.id, type: b.type, position: b.position })) - const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, { - targetParentId: containerInfo.loopId, - existingChildBlocks, - containerId: containerInfo.loopId, - }) + const autoConnectEdge = resolveEdge(id, containerInfo.loopId, () => + tryCreateAutoConnectEdge(relativePosition, id, { + targetParentId: containerInfo.loopId, + existingChildBlocks, + containerId: containerInfo.loopId, + }) + ) addBlock( id, @@ -1857,9 +1898,11 @@ const WorkflowContent = React.memo( resizeLoopNodesWrapper() } else { - const autoConnectEdge = tryCreateAutoConnectEdge(position, id, { - targetParentId: null, - }) + const autoConnectEdge = resolveEdge(id, null, () => + tryCreateAutoConnectEdge(position, id, { + targetParentId: null, + }) + ) addBlock( id, @@ -1924,11 +1967,13 @@ const WorkflowContent = React.memo( .filter((b) => b.data?.parentId === containerInfo.loopId) .map((b) => ({ id: b.id, type: b.type, position: b.position })) - const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, { - targetParentId: containerInfo.loopId, - existingChildBlocks, - containerId: containerInfo.loopId, - }) + const autoConnectEdge = resolveEdge(id, containerInfo.loopId, () => + tryCreateAutoConnectEdge(relativePosition, id, { + targetParentId: containerInfo.loopId, + existingChildBlocks, + containerId: containerInfo.loopId, + }) + ) // Add block with parent info AND autoConnectEdge (atomic operation) addBlock( @@ -1954,9 +1999,11 @@ const WorkflowContent = React.memo( // Centralized trigger constraints if (checkTriggerConstraints(data.type)) return - const autoConnectEdge = tryCreateAutoConnectEdge(position, id, { - targetParentId: null, - }) + const autoConnectEdge = resolveEdge(id, null, () => + tryCreateAutoConnectEdge(position, id, { + targetParentId: null, + }) + ) // Regular canvas drop with auto-connect edge // Use enableTriggerMode from drag data if present (when dragging from Triggers tab) @@ -1985,6 +2032,7 @@ const WorkflowContent = React.memo( addBlock, tryCreateAutoConnectEdge, checkTriggerConstraints, + createEdgeObject, ] ) @@ -2001,65 +2049,38 @@ const WorkflowContent = React.memo( if (typeof type !== 'string' || !type) return if (type === 'connectionBlock') return - // Consume a pending drag-release only when THIS event is the palette - // selection it opened, matched by correlation token. Any other - // add-block event (toolbar, sidebar, command list) carries no matching - // token and cannot inherit the pending position/source. + // Complete a pending drag-release only when THIS event is the palette + // selection it opened (matched by correlation token). Any other add-block + // event (toolbar, sidebar, command list) carries no matching token and + // cannot inherit the pending position/source. Delegating to handleToolbarDrop + // with the drag source gives container-aware placement AND an edge from the + // released handle that respects container boundaries, exactly like onConnect. const pendingRef = pendingConnectRef.current - const pending = - pendingRef && typeof connectToken === 'string' && connectToken === pendingRef.token - ? pendingRef - : null - if (pending) pendingConnectRef.current = null - - let basePosition = getViewportCenter() - if (pending) { - // screenToFlowPosition already subtracts the pane rect internally — pass - // raw client coords, do NOT pre-subtract container bounds. - basePosition = screenToFlowPosition({ x: pending.screenX, y: pending.screenY }) - - // Released inside a loop/parallel container: delegate to the container-aware - // drop path for correct parenting, clamping, and in-container auto-connect. - // A forced source→target edge would cross the container boundary, so it is - // intentionally not applied here. - if (isPointInLoopNode(basePosition)) { - handleToolbarDrop( - { - type, - enableTriggerMode: enableTriggerMode === true, - presetOperation: typeof presetOperation === 'string' ? presetOperation : undefined, - }, - basePosition - ) - return - } + if (pendingRef && typeof connectToken === 'string' && connectToken === pendingRef.token) { + pendingConnectRef.current = null + // screenToFlowPosition subtracts the pane rect internally — pass raw client coords. + handleToolbarDrop( + { + type, + enableTriggerMode: enableTriggerMode === true, + presetOperation: typeof presetOperation === 'string' ? presetOperation : undefined, + forcedSource: pendingRef.source, + }, + screenToFlowPosition({ x: pendingRef.screenX, y: pendingRef.screenY }) + ) + return } - // Force the source→new-block edge only when it stays root-to-root, matching - // the root-level placement — a source inside a container (or a container-start - // handle) would cross the boundary and is rejected, as in onConnect. - const sourceBlock = pending ? blocks[pending.source.nodeId] : undefined - const isContainerStartHandle = - pending?.source.handleId === 'loop-start-source' || - pending?.source.handleId === 'parallel-start-source' - const canForceEdge = - Boolean(sourceBlock) && !sourceBlock?.data?.parentId && !isContainerStartHandle - - /** - * Edge for the new block: forced from the drag source when the palette was - * opened by a connection release, otherwise the normal auto-connect edge. - */ - const resolveAutoConnectEdge = (targetId: string): Edge | undefined => - pending && canForceEdge - ? createEdgeObject(pending.source.nodeId, targetId, pending.source.handleId) - : tryCreateAutoConnectEdge(basePosition, targetId, { targetParentId: null }) + const basePosition = getViewportCenter() if (type === 'loop' || type === 'parallel') { const id = generateId() const baseName = type === 'loop' ? 'Loop' : 'Parallel' const name = getUniqueBlockName(baseName, blocks) - const autoConnectEdge = resolveAutoConnectEdge(id) + const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, { + targetParentId: null, + }) addBlock( id, @@ -2092,7 +2113,9 @@ const WorkflowContent = React.memo( const baseName = defaultTriggerName || blockConfig.name const name = getUniqueBlockName(baseName, blocks) - const autoConnectEdge = resolveAutoConnectEdge(id) + const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, { + targetParentId: null, + }) addBlock( id, @@ -2126,9 +2149,7 @@ const WorkflowContent = React.memo( checkTriggerConstraints, tryCreateAutoConnectEdge, screenToFlowPosition, - createEdgeObject, handleToolbarDrop, - isPointInLoopNode, ]) /** From f5443b7d2e0f6b69282105a085f8485b51edacd3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 20:06:01 -0700 Subject: [PATCH 6/6] refactor(workflow-editor): collapse drag-release correlation into one store field --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 71 +++++-------------- .../components/search-modal/search-modal.tsx | 4 +- apps/sim/stores/modals/search/store.ts | 8 +-- apps/sim/stores/modals/search/types.ts | 30 +++++--- 4 files changed, 44 insertions(+), 69 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index ba4de8a2053..4f9da917a1e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -95,6 +95,7 @@ import { useCanvasModeStore } from '@/stores/canvas-mode' import { useChatStore } from '@/stores/chat/store' import { defaultWorkflowExecutionState, useExecutionStore } from '@/stores/execution' import { useSearchModalStore } from '@/stores/modals/search/store' +import type { PendingConnect } from '@/stores/modals/search/types' import { usePanelEditorStore } from '@/stores/panel' import { useUndoRedoStore } from '@/stores/undo-redo' import { useVariablesModalStore } from '@/stores/variables/modal' @@ -209,7 +210,7 @@ interface AddBlockFromToolbarDetail { type?: unknown enableTriggerMode?: unknown presetOperation?: unknown - connectToken?: unknown + pendingConnect?: PendingConnect } /** @@ -530,19 +531,6 @@ const WorkflowContent = React.memo( /** Tracks whether onConnect successfully handled the connection (ReactFlow pattern). */ const connectionCompletedRef = useRef(false) - /** - * Holds the drag origin + drop point when a connection is released on empty - * canvas and the command palette is opened. The block chosen from the palette - * is placed at the drop point and wired from this source handle. Cleared when - * the palette closes without a selection. - */ - const pendingConnectRef = useRef<{ - source: { nodeId: string; handleId: string } - screenX: number - screenY: number - token: string - } | null>(null) - /** Stores start positions for multi-node drag undo/redo recording. */ const multiNodeDragStartRef = useRef>( new Map() @@ -2044,29 +2032,26 @@ const WorkflowContent = React.memo( return } - const { type, enableTriggerMode, presetOperation, connectToken } = event.detail + const { type, enableTriggerMode, presetOperation, pendingConnect } = event.detail if (typeof type !== 'string' || !type) return if (type === 'connectionBlock') return - // Complete a pending drag-release only when THIS event is the palette - // selection it opened (matched by correlation token). Any other add-block - // event (toolbar, sidebar, command list) carries no matching token and - // cannot inherit the pending position/source. Delegating to handleToolbarDrop - // with the drag source gives container-aware placement AND an edge from the - // released handle that respects container boundaries, exactly like onConnect. - const pendingRef = pendingConnectRef.current - if (pendingRef && typeof connectToken === 'string' && connectToken === pendingRef.token) { - pendingConnectRef.current = null + // Complete an edge drag-release: only a genuine palette selection carries + // `pendingConnect` (other add-block dispatchers — toolbar, sidebar, command + // list — don't), so its presence is the signal. Delegating to + // handleToolbarDrop with the drag source gives container-aware placement AND + // an edge from the released handle that respects container boundaries. + if (pendingConnect) { // screenToFlowPosition subtracts the pane rect internally — pass raw client coords. handleToolbarDrop( { type, enableTriggerMode: enableTriggerMode === true, presetOperation: typeof presetOperation === 'string' ? presetOperation : undefined, - forcedSource: pendingRef.source, + forcedSource: pendingConnect.source, }, - screenToFlowPosition({ x: pendingRef.screenX, y: pendingRef.screenY }) + screenToFlowPosition({ x: pendingConnect.screenX, y: pendingConnect.screenY }) ) return } @@ -2152,22 +2137,6 @@ const WorkflowContent = React.memo( handleToolbarDrop, ]) - /** - * Drops a pending drag-release connection if the command palette is dismissed - * without picking a block. A real selection consumes the ref synchronously (via - * the `add-block-from-toolbar` event) before the modal closes, so a ref still - * set on close means the user cancelled. - */ - useEffect( - () => - useSearchModalStore.subscribe((state, prev) => { - if (prev.isOpen && !state.isOpen) { - pendingConnectRef.current = null - } - }), - [] - ) - /** * Listen for toolbar drops that occur on the empty-workflow overlay (command list). * @@ -3245,19 +3214,15 @@ const WorkflowContent = React.memo( targetHandle: 'target', }) } else if (!targetNode) { - // Released on empty canvas: open the command palette and remember the - // drag origin + drop point so the chosen block lands here, wired from - // this source handle. - const connectToken = generateId() - pendingConnectRef.current = { - source: { nodeId: source.nodeId, handleId: source.handleId }, - screenX: clientPos.clientX, - screenY: clientPos.clientY, - token: connectToken, - } + // Released on empty canvas: open the command palette with the drag origin + // + drop point, so the chosen block lands here wired from this handle. useSearchModalStore.getState().open({ sections: ['blocks', 'tools', 'toolOperations'], - connectToken, + pendingConnect: { + source: { nodeId: source.nodeId, handleId: source.handleId }, + screenX: clientPos.clientX, + screenY: clientPos.clientY, + }, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 5c4b2c54a1c..fc60cd431ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -351,7 +351,7 @@ export function SearchModal({ detail: { type: block.type, enableTriggerMode, - connectToken: useSearchModalStore.getState().connectToken, + pendingConnect: useSearchModalStore.getState().pendingConnect, }, }) ) @@ -372,7 +372,7 @@ export function SearchModal({ detail: { type: op.blockType, presetOperation: op.operationId, - connectToken: useSearchModalStore.getState().connectToken, + pendingConnect: useSearchModalStore.getState().pendingConnect, }, }) ) diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index fb73bfff850..65a84b2d627 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -68,23 +68,23 @@ export const useSearchModalStore = create()( (set, _) => ({ isOpen: false, sections: null, - connectToken: null, + pendingConnect: null, data: initialData, setOpen: (open: boolean) => { - set({ isOpen: open, sections: null, connectToken: null }) + set({ isOpen: open, sections: null, pendingConnect: null }) }, open: (options) => { set({ isOpen: true, sections: options?.sections ?? null, - connectToken: options?.connectToken ?? null, + pendingConnect: options?.pendingConnect ?? null, }) }, close: () => { - set({ isOpen: false, sections: null, connectToken: null }) + set({ isOpen: false, sections: null, pendingConnect: null }) }, initializeData: (filterBlocks) => { diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index eec30feec5f..b8315e8156e 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -75,6 +75,18 @@ export const SEARCH_SECTIONS = [ /** A single search-modal result group. */ export type SearchSection = (typeof SEARCH_SECTIONS)[number] +/** + * Context handed to the palette when it is opened to complete an edge + * drag-release: the dragged source handle and the release point. A selection + * stamps it onto its event so the canvas places the block at the drop point and + * wires it from that handle. + */ +export interface PendingConnect { + source: { nodeId: string; handleId: string } + screenX: number + screenY: number +} + /** * Global state for the universal search modal. * @@ -92,29 +104,27 @@ export interface SearchModalState { sections: SearchSection[] | null /** - * Correlation token for the open session. When the palette is opened to - * complete a specific action (e.g. an edge drag-release), the opener passes a - * token that a selection stamps onto its event, so only that selection — not - * an unrelated concurrent add — can consume the pending action. `null` for - * ordinary opens. + * Pending edge drag-release the palette was opened to complete. A selection + * stamps it onto its event; other add-block dispatchers carry none, so only a + * genuine palette pick completes the connection. `null` for ordinary opens. */ - connectToken: string | null + pendingConnect: PendingConnect | null /** Pre-computed search data. */ data: SearchData /** * Explicitly set the open state of the modal. Always resets to the full - * palette (no section restriction, no correlation token). + * palette (no section restriction, no pending connect). */ setOpen: (open: boolean) => void /** * Convenience method to open the modal. Pass `sections` to restrict the - * palette to a subset of result groups, and `connectToken` to correlate a - * selection with a pending action. + * palette to a subset of result groups, and `pendingConnect` to complete an + * edge drag-release with the selection. */ - open: (options?: { sections?: SearchSection[]; connectToken?: string }) => void + open: (options?: { sections?: SearchSection[]; pendingConnect?: PendingConnect }) => void /** * Convenience method to close the modal.