feat(workflow-editor): open block palette on edge drag-release with auto-connect (#5586)

* feat(workflow-editor): open block palette on edge drag-release with auto-connect

* fix(workflow-editor): correct drag-release drop coords, scoping, and container placement

* fix(workflow-editor): correlate drag-release palette selection with a token

* fix(workflow-editor): preserve tool operation preset on in-container drag-release

* fix(workflow-editor): wire drag-release edge from the actual source handle via handleToolbarDrop

* refactor(workflow-editor): collapse drag-release correlation into one store field
This commit is contained in:
Theodore Li
2026-07-11 13:40:33 -04:00
committed by GitHub
parent 3d02bbbe62
commit fca5f10f86
4 changed files with 289 additions and 99 deletions
@@ -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
@@ -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.
</Command.Empty>
<ActionsGroup
items={filteredActions}
onSelect={handleActionSelect}
query={deferredSearch}
/>
<ConnectedAccountsGroup
items={filteredConnectedAccounts}
onSelect={handleConnectedAccountSelect}
query={deferredSearch}
/>
<IntegrationsGroup
items={filteredIntegrations}
onSelect={handleIntegrationSelect}
query={deferredSearch}
/>
<BlocksGroup
items={filteredBlocks}
onSelect={handleBlockSelectAsBlock}
query={deferredSearch}
/>
<ToolsGroup
items={filteredTools}
onSelect={handleBlockSelectAsTool}
query={deferredSearch}
/>
<TriggersGroup
items={filteredTriggers}
onSelect={handleBlockSelectAsTrigger}
query={deferredSearch}
/>
<ChatsGroup
items={filteredChats}
onSelect={handleChatSelect}
query={deferredSearch}
/>
<WorkflowsGroup
items={filteredWorkflows}
onSelect={handleWorkflowSelect}
query={deferredSearch}
/>
<TablesGroup
items={filteredTables}
onSelect={handleTableSelect}
query={deferredSearch}
/>
<FilesGroup
items={filteredFiles}
onSelect={handleFileSelect}
query={deferredSearch}
/>
<KnowledgeBasesGroup
items={filteredKnowledgeBases}
onSelect={handleKbSelect}
query={deferredSearch}
/>
<ToolOpsGroup
items={filteredToolOps}
onSelect={handleToolOperationSelect}
query={deferredSearch}
/>
<WorkspacesGroup
items={filteredWorkspaces}
onSelect={handleWorkspaceSelect}
query={deferredSearch}
/>
<DocsGroup items={filteredDocs} onSelect={handleDocSelect} query={deferredSearch} />
<PagesGroup
items={filteredPages}
onSelect={handlePageSelect}
query={deferredSearch}
/>
{showSection('actions') && (
<ActionsGroup
items={filteredActions}
onSelect={handleActionSelect}
query={deferredSearch}
/>
)}
{showSection('connectedAccounts') && (
<ConnectedAccountsGroup
items={filteredConnectedAccounts}
onSelect={handleConnectedAccountSelect}
query={deferredSearch}
/>
)}
{showSection('integrations') && (
<IntegrationsGroup
items={filteredIntegrations}
onSelect={handleIntegrationSelect}
query={deferredSearch}
/>
)}
{showSection('blocks') && (
<BlocksGroup
items={filteredBlocks}
onSelect={handleBlockSelectAsBlock}
query={deferredSearch}
/>
)}
{showSection('tools') && (
<ToolsGroup
items={filteredTools}
onSelect={handleBlockSelectAsTool}
query={deferredSearch}
/>
)}
{showSection('triggers') && (
<TriggersGroup
items={filteredTriggers}
onSelect={handleBlockSelectAsTrigger}
query={deferredSearch}
/>
)}
{showSection('chats') && (
<ChatsGroup
items={filteredChats}
onSelect={handleChatSelect}
query={deferredSearch}
/>
)}
{showSection('workflows') && (
<WorkflowsGroup
items={filteredWorkflows}
onSelect={handleWorkflowSelect}
query={deferredSearch}
/>
)}
{showSection('tables') && (
<TablesGroup
items={filteredTables}
onSelect={handleTableSelect}
query={deferredSearch}
/>
)}
{showSection('files') && (
<FilesGroup
items={filteredFiles}
onSelect={handleFileSelect}
query={deferredSearch}
/>
)}
{showSection('knowledgeBases') && (
<KnowledgeBasesGroup
items={filteredKnowledgeBases}
onSelect={handleKbSelect}
query={deferredSearch}
/>
)}
{showSection('toolOperations') && (
<ToolOpsGroup
items={filteredToolOps}
onSelect={handleToolOperationSelect}
query={deferredSearch}
/>
)}
{showSection('workspaces') && (
<WorkspacesGroup
items={filteredWorkspaces}
onSelect={handleWorkspaceSelect}
query={deferredSearch}
/>
)}
{showSection('docs') && (
<DocsGroup items={filteredDocs} onSelect={handleDocSelect} query={deferredSearch} />
)}
{showSection('pages') && (
<PagesGroup
items={filteredPages}
onSelect={handlePageSelect}
query={deferredSearch}
/>
)}
</Command.List>
</Command>
</div>
+10 -4
View File
@@ -67,18 +67,24 @@ export const useSearchModalStore = create<SearchModalState>()(
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) => {
+56 -3
View File
@@ -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.