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.