diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 4ab924e8f3d..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,6 +210,7 @@ interface AddBlockFromToolbarDetail { type?: unknown enableTriggerMode?: unknown presetOperation?: unknown + pendingConnect?: PendingConnect } /** @@ -1779,9 +1781,55 @@ 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 + forcedSource?: { nodeId: string; handleId: string } + }, + position: { x: number; y: number } + ) => { if (!data.type || data.type === 'connectionBlock') return + const operationConfig = data.presetOperation + ? { 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) @@ -1811,11 +1859,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, @@ -1836,9 +1886,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, @@ -1903,11 +1955,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( @@ -1921,7 +1975,9 @@ const WorkflowContent = React.memo( }, containerInfo.loopId, 'parent', - autoConnectEdge + autoConnectEdge, + undefined, + operationConfig ) // Resize the container node to fit the new block @@ -1931,9 +1987,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) @@ -1947,7 +2005,8 @@ const WorkflowContent = React.memo( undefined, undefined, autoConnectEdge, - enableTriggerMode + enableTriggerMode, + operationConfig ) } } catch (err) { @@ -1961,6 +2020,7 @@ const WorkflowContent = React.memo( addBlock, tryCreateAutoConnectEdge, checkTriggerConstraints, + createEdgeObject, ] ) @@ -1972,11 +2032,30 @@ const WorkflowContent = React.memo( return } - const { type, enableTriggerMode, presetOperation } = event.detail + const { type, enableTriggerMode, presetOperation, pendingConnect } = event.detail if (typeof type !== 'string' || !type) return if (type === 'connectionBlock') return + // 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: pendingConnect.source, + }, + screenToFlowPosition({ x: pendingConnect.screenX, y: pendingConnect.screenY }) + ) + return + } + const basePosition = getViewportCenter() if (type === 'loop' || type === 'parallel') { @@ -2054,6 +2133,8 @@ const WorkflowContent = React.memo( effectivePermissions.canEdit, checkTriggerConstraints, tryCreateAutoConnectEdge, + screenToFlowPosition, + handleToolbarDrop, ]) /** @@ -3132,6 +3213,17 @@ const WorkflowContent = React.memo( target: targetNode.id, targetHandle: 'target', }) + } else if (!targetNode) { + // 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'], + pendingConnect: { + source: { nodeId: source.nodeId, handleId: source.handleId }, + screenX: clientPos.clientX, + screenY: clientPos.clientY, + }, + }) } 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..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 @@ -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')) }, []) @@ -347,6 +351,7 @@ export function SearchModal({ detail: { type: block.type, enableTriggerMode, + pendingConnect: useSearchModalStore.getState().pendingConnect, }, }) ) @@ -364,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, + pendingConnect: useSearchModalStore.getState().pendingConnect, + }, }) ) captureEvent(posthogRef.current, 'search_result_selected', { @@ -690,77 +699,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..65a84b2d627 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -67,18 +67,24 @@ export const useSearchModalStore = create()( devtools( (set, _) => ({ isOpen: false, + sections: null, + pendingConnect: null, data: initialData, setOpen: (open: boolean) => { - set({ isOpen: open }) + set({ isOpen: open, sections: null, pendingConnect: null }) }, - open: () => { - set({ isOpen: true }) + open: (options) => { + set({ + isOpen: true, + sections: options?.sections ?? null, + pendingConnect: options?.pendingConnect ?? null, + }) }, close: () => { - set({ isOpen: false }) + 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 b276f23627a..b8315e8156e 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -49,6 +49,44 @@ 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] + +/** + * 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. * @@ -60,18 +98,33 @@ 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 + + /** + * 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. + */ + pendingConnect: PendingConnect | 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, no pending connect). */ 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, and `pendingConnect` to complete an + * edge drag-release with the selection. */ - open: () => void + open: (options?: { sections?: SearchSection[]; pendingConnect?: PendingConnect }) => void /** * Convenience method to close the modal.