From a0ebe0842be6530e0be8804cae4e4bdcae0c7ea2 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Feb 2026 00:39:22 -0800 Subject: [PATCH] fix(tool-input): restore optional indicator, fix folder selector and canonical toggle, extract components - Attach resolved paramVisibility to subblocks from getSubBlocksForToolInput - Add labelSuffix prop to SubBlock for "(optional)" badge on user-or-llm params - Fix folder selector missing for tools with canonicalParamId (e.g. Google Drive) - Fix canonical toggle not clickable by letting SubBlock handle dependsOn internally - Extract ParameterWithLabel, ToolSubBlockRenderer, ToolCredentialSelector to components/tools/ - Extract StoredTool interface to types.ts, selection helpers to utils.ts - Remove dead code (mcpError, refreshTools, oldParamIds, initialParams) - Strengthen typing: replace any with proper types on icon components and evaluateParameterCondition --- .../components/tool-sub-block-renderer.tsx | 410 -------- .../credential-selector.tsx} | 0 .../tool-input/components/tools/parameter.tsx | 189 ++++ .../components/tools/sub-block-renderer.tsx | 109 ++ .../components/tool-input/tool-input.test.ts | 37 +- .../components/tool-input/tool-input.tsx | 948 ++++++------------ .../sub-block/components/tool-input/types.ts | 31 + .../sub-block/components/tool-input/utils.ts | 32 + .../editor/components/sub-block/sub-block.tsx | 12 +- apps/sim/tools/params.ts | 22 +- 10 files changed, 720 insertions(+), 1070 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-sub-block-renderer.tsx rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/{tool-credential-selector.tsx => tools/credential-selector.tsx} (100%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-sub-block-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-sub-block-renderer.tsx deleted file mode 100644 index bb8c26f9c..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-sub-block-renderer.tsx +++ /dev/null @@ -1,410 +0,0 @@ -'use client' - -import type React from 'react' -import { useCallback, useEffect, useRef } from 'react' -import { Combobox, Switch } from '@/components/emcn' -import { - CheckboxList, - Code, - DocumentSelector, - DocumentTagEntry, - FileSelectorInput, - FileUpload, - FolderSelectorInput, - KnowledgeBaseSelector, - KnowledgeTagFilters, - LongInput, - ProjectSelectorInput, - SheetSelectorInput, - ShortInput, - SlackSelectorInput, - SliderInput, - Table, - TimeInput, - WorkflowSelectorInput, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components' -import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' -import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' -import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' -import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' -import { isPasswordParameter } from '@/tools/params' - -interface ToolSubBlockRendererProps { - blockId: string - subBlockId: string - toolIndex: number - subBlock: BlockSubBlockConfig - effectiveParamId: string - toolParams: Record | undefined - onParamChange: (toolIndex: number, paramId: string, value: string) => void - disabled: boolean - previewContextValues?: Record - wandControlRef?: React.MutableRefObject -} - -/** - * Renders a subblock component inside tool-input by bridging the subblock store - * with StoredTool.params via a synthetic store key. - * - * Replaces the 17+ individual SyncWrapper components that previously existed. - * Components read/write to the store at a synthetic ID, and two effects - * handle bidirectional sync with tool.params. - */ -export function ToolSubBlockRenderer({ - blockId, - subBlockId, - toolIndex, - subBlock, - effectiveParamId, - toolParams, - onParamChange, - disabled, - previewContextValues, - wandControlRef, -}: ToolSubBlockRendererProps) { - const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}` - const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId) - - // Gate the component using the same dependsOn logic as SubBlock - const { finalDisabled } = useDependsOnGate(blockId, subBlock, { - disabled, - previewContextValues, - }) - - const toolParamValue = toolParams?.[effectiveParamId] ?? '' - - /** Tracks the last value we wrote to the store from tool.params to avoid echo loops */ - const lastInitRef = useRef(toolParamValue) - /** Tracks the last value we synced back to tool.params from the store */ - const lastSyncRef = useRef(toolParamValue) - - // Init effect: push tool.params value into the store when it changes externally - useEffect(() => { - if (toolParamValue !== lastInitRef.current) { - lastInitRef.current = toolParamValue - lastSyncRef.current = toolParamValue - setStoreValue(toolParamValue) - } - }, [toolParamValue, setStoreValue]) - - // Sync effect: when the store changes (user interaction), push back to tool.params - useEffect(() => { - if (storeValue == null) return - const stringValue = typeof storeValue === 'string' ? storeValue : JSON.stringify(storeValue) - if (stringValue !== lastSyncRef.current) { - lastSyncRef.current = stringValue - lastInitRef.current = stringValue - onParamChange(toolIndex, effectiveParamId, stringValue) - } - }, [storeValue, toolIndex, effectiveParamId, onParamChange]) - - // Initialize the store on first mount - const hasInitializedRef = useRef(false) - useEffect(() => { - if (!hasInitializedRef.current && toolParamValue) { - hasInitializedRef.current = true - setStoreValue(toolParamValue) - } - }, []) // eslint-disable-line react-hooks/exhaustive-deps - - const configWithSyntheticId = { ...subBlock, id: syntheticId } - - return renderSubBlockComponent({ - blockId, - syntheticId, - config: configWithSyntheticId, - subBlock, - disabled: finalDisabled, - previewContextValues, - wandControlRef, - toolParamValue, - onParamChange: useCallback( - (value: string) => onParamChange(toolIndex, effectiveParamId, value), - [toolIndex, effectiveParamId, onParamChange] - ), - }) -} - -interface RenderContext { - blockId: string - syntheticId: string - config: BlockSubBlockConfig - subBlock: BlockSubBlockConfig - disabled: boolean - previewContextValues?: Record - wandControlRef?: React.MutableRefObject - toolParamValue: string - onParamChange: (value: string) => void -} - -/** - * Renders the appropriate component for a subblock type. - * Mirrors the switch cases in SubBlock's renderInput(), using - * the same component props pattern. - */ -function renderSubBlockComponent(ctx: RenderContext): React.ReactNode { - const { - blockId, - syntheticId, - config, - subBlock, - disabled, - previewContextValues, - wandControlRef, - toolParamValue, - onParamChange, - } = ctx - - switch (subBlock.type) { - case 'short-input': - return ( - - ) - - case 'long-input': - return ( - - ) - - case 'dropdown': - return ( - option.id !== '') - .map((option) => ({ - label: option.label, - value: option.id, - })) || [] - } - value={toolParamValue} - onChange={onParamChange} - placeholder={subBlock.placeholder || 'Select option'} - disabled={disabled} - /> - ) - - case 'switch': - return ( - onParamChange(checked ? 'true' : 'false')} - /> - ) - - case 'code': - return ( - - ) - - case 'channel-selector': - case 'user-selector': - return ( - - ) - - case 'project-selector': - return ( - - ) - - case 'file-selector': - return ( - - ) - - case 'sheet-selector': - return ( - - ) - - case 'folder-selector': - return ( - - ) - - case 'knowledge-base-selector': - return - - case 'document-selector': - return ( - - ) - - case 'document-tag-entry': - return ( - - ) - - case 'knowledge-tag-filters': - return ( - - ) - - case 'table': - return ( - - ) - - case 'slider': - return ( - - ) - - case 'checkbox-list': - return ( - - ) - - case 'time-input': - return ( - - ) - - case 'file-upload': - return ( - - ) - - case 'combobox': - return ( - ({ - label: opt.label, - value: opt.id, - }) - )} - value={toolParamValue} - onChange={onParamChange} - placeholder={subBlock.placeholder || 'Select option'} - disabled={disabled} - /> - ) - - case 'workflow-selector': - return - - case 'oauth-input': - // OAuth inputs are handled separately by ToolCredentialSelector in the parent - return null - - default: - return ( - - ) - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter.tsx new file mode 100644 index 000000000..fe9ed3274 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter.tsx @@ -0,0 +1,189 @@ +'use client' + +import type React from 'react' +import { useRef, useState } from 'react' +import { ArrowLeftRight, ArrowUp } from 'lucide-react' +import { Button, Input, Label, Tooltip } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' + +/** + * Props for a generic parameter with label component + */ +export interface ParameterWithLabelProps { + paramId: string + title: string + isRequired: boolean + visibility: string + wandConfig?: { + enabled: boolean + prompt?: string + placeholder?: string + } + canonicalToggle?: { + mode: 'basic' | 'advanced' + disabled?: boolean + onToggle?: () => void + } + disabled: boolean + isPreview: boolean + children: (wandControlRef: React.MutableRefObject) => React.ReactNode +} + +/** + * Generic wrapper component for parameters that manages wand state and renders label + input + */ +export function ParameterWithLabel({ + paramId, + title, + isRequired, + visibility, + wandConfig, + canonicalToggle, + disabled, + isPreview, + children, +}: ParameterWithLabelProps) { + const [isSearchActive, setIsSearchActive] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const searchInputRef = useRef(null) + const wandControlRef = useRef(null) + + const isWandEnabled = wandConfig?.enabled ?? false + const showWand = isWandEnabled && !isPreview && !disabled + + const handleSearchClick = (): void => { + setIsSearchActive(true) + setTimeout(() => { + searchInputRef.current?.focus() + }, 0) + } + + const handleSearchBlur = (): void => { + if (!searchQuery.trim() && !wandControlRef.current?.isWandStreaming) { + setIsSearchActive(false) + } + } + + const handleSearchChange = (value: string): void => { + setSearchQuery(value) + } + + const handleSearchSubmit = (): void => { + if (searchQuery.trim() && wandControlRef.current) { + wandControlRef.current.onWandTrigger(searchQuery) + setSearchQuery('') + setIsSearchActive(false) + } + } + + const handleSearchCancel = (): void => { + setSearchQuery('') + setIsSearchActive(false) + } + + const isStreaming = wandControlRef.current?.isWandStreaming ?? false + + return ( +
+
+ +
+ {showWand && + (!isSearchActive ? ( + + ) : ( +
+ ) => + handleSearchChange(e.target.value) + } + onBlur={(e: React.FocusEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement | null + if (relatedTarget?.closest('button')) return + handleSearchBlur() + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) { + handleSearchSubmit() + } else if (e.key === 'Escape') { + handleSearchCancel() + } + }} + disabled={isStreaming} + className={cn( + 'h-5 min-w-[80px] flex-1 text-[11px]', + isStreaming && 'text-muted-foreground' + )} + placeholder='Generate with AI...' + /> + +
+ ))} + {canonicalToggle && !isPreview && ( + + + + + +

+ {canonicalToggle.mode === 'advanced' + ? 'Switch to selector' + : 'Switch to manual ID'} +

+
+
+ )} +
+
+
{children(wandControlRef)}
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx new file mode 100644 index 000000000..a158cb763 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useEffect, useMemo, useRef } from 'react' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' +import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' + +interface ToolSubBlockRendererProps { + blockId: string + subBlockId: string + toolIndex: number + subBlock: BlockSubBlockConfig + effectiveParamId: string + toolParams: Record | undefined + onParamChange: (toolIndex: number, paramId: string, value: string) => void + disabled: boolean + canonicalToggle?: { + mode: 'basic' | 'advanced' + disabled?: boolean + onToggle?: () => void + } +} + +/** + * Bridges the subblock store with StoredTool.params via a synthetic store key, + * then delegates all rendering to SubBlock for full parity. + * + * Two effects handle bidirectional sync: + * - tool.params → store (external changes) + * - store → tool.params (user interaction) + */ +export function ToolSubBlockRenderer({ + blockId, + subBlockId, + toolIndex, + subBlock, + effectiveParamId, + toolParams, + onParamChange, + disabled, + canonicalToggle, +}: ToolSubBlockRendererProps) { + const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}` + const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId) + + const toolParamValue = toolParams?.[effectiveParamId] ?? '' + + /** Tracks the last value we pushed to the store from tool.params to avoid echo loops */ + const lastPushedToStoreRef = useRef(null) + /** Tracks the last value we synced back to tool.params from the store */ + const lastPushedToParamsRef = useRef(null) + + // Sync tool.params → store: push when the prop value changes (including first mount) + useEffect(() => { + if (!toolParamValue && lastPushedToStoreRef.current === null) { + // Skip initializing the store with an empty value on first mount — + // let the SubBlock component use its own default. + lastPushedToStoreRef.current = toolParamValue + lastPushedToParamsRef.current = toolParamValue + return + } + if (toolParamValue !== lastPushedToStoreRef.current) { + lastPushedToStoreRef.current = toolParamValue + lastPushedToParamsRef.current = toolParamValue + setStoreValue(toolParamValue) + } + }, [toolParamValue, setStoreValue]) + + // Sync store → tool.params: push when the user changes the value via SubBlock + useEffect(() => { + if (storeValue == null) return + const stringValue = typeof storeValue === 'string' ? storeValue : JSON.stringify(storeValue) + if (stringValue !== lastPushedToParamsRef.current) { + lastPushedToParamsRef.current = stringValue + lastPushedToStoreRef.current = stringValue + onParamChange(toolIndex, effectiveParamId, stringValue) + } + }, [storeValue, toolIndex, effectiveParamId, onParamChange]) + + // Determine if the parameter is optional for the user (LLM can fill it) + const visibility = subBlock.paramVisibility ?? 'user-or-llm' + const isOptionalForUser = visibility !== 'user-only' + + const labelSuffix = useMemo( + () => + isOptionalForUser ? ( + (optional) + ) : null, + [isOptionalForUser] + ) + + // Suppress SubBlock's "*" required indicator for optional-for-user params + const config = { + ...subBlock, + id: syntheticId, + ...(isOptionalForUser && { required: false }), + } + + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts index 8d2548c13..44b73e1e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts @@ -2,37 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' - -interface StoredTool { - type: string - title?: string - toolId?: string - params?: Record - customToolId?: string - schema?: any - code?: string - operation?: string - usageControl?: 'auto' | 'force' | 'none' -} - -const isMcpToolAlreadySelected = (selectedTools: StoredTool[], mcpToolId: string): boolean => { - return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId) -} - -const isCustomToolAlreadySelected = ( - selectedTools: StoredTool[], - customToolId: string -): boolean => { - return selectedTools.some( - (tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId - ) -} - -const isWorkflowAlreadySelected = (selectedTools: StoredTool[], workflowId: string): boolean => { - return selectedTools.some( - (tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId - ) -} +import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types' +import { + isCustomToolAlreadySelected, + isMcpToolAlreadySelected, + isWorkflowAlreadySelected, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils' describe('isMcpToolAlreadySelected', () => { describe('basic functionality', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 49ba83688..b35d59e64 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -1,16 +1,13 @@ import type React from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { ArrowLeftRight, ArrowUp, Loader2, WrenchIcon, XIcon } from 'lucide-react' +import { Loader2, WrenchIcon, XIcon } from 'lucide-react' import { useParams } from 'next/navigation' import { Badge, - Button, Combobox, type ComboboxOption, type ComboboxOptionGroup, - Input, - Label, Popover, PopoverContent, PopoverItem, @@ -26,6 +23,7 @@ import { isToolUnavailable, getMcpToolIssue as validateMcpTool, } from '@/lib/mcp/tool-validation' +import type { McpToolSchema } from '@/lib/mcp/types' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, @@ -42,8 +40,15 @@ import { type CustomTool, CustomToolModal, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' -import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector' -import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-sub-block-renderer' +import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector' +import { ParameterWithLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter' +import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer' +import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types' +import { + isCustomToolAlreadySelected, + isMcpToolAlreadySelected, + isWorkflowAlreadySelected, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' import { getAllBlocks } from '@/blocks' @@ -92,281 +97,6 @@ import { const logger = createLogger('ToolInput') -/** - * Props for a generic parameter with label component - */ -interface ParameterWithLabelProps { - paramId: string - title: string - isRequired: boolean - visibility: string - wandConfig?: { - enabled: boolean - prompt?: string - placeholder?: string - } - canonicalToggle?: { - mode: 'basic' | 'advanced' - disabled?: boolean - onToggle?: () => void - } - disabled: boolean - isPreview: boolean - children: (wandControlRef: React.MutableRefObject) => React.ReactNode -} - -/** - * Generic wrapper component for parameters that manages wand state and renders label + input - */ -const ParameterWithLabel: React.FC = ({ - paramId, - title, - isRequired, - visibility, - wandConfig, - canonicalToggle, - disabled, - isPreview, - children, -}) => { - const [isSearchActive, setIsSearchActive] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const searchInputRef = useRef(null) - const wandControlRef = useRef(null) - - const isWandEnabled = wandConfig?.enabled ?? false - const showWand = isWandEnabled && !isPreview && !disabled - - const handleSearchClick = (): void => { - setIsSearchActive(true) - setTimeout(() => { - searchInputRef.current?.focus() - }, 0) - } - - const handleSearchBlur = (): void => { - if (!searchQuery.trim() && !wandControlRef.current?.isWandStreaming) { - setIsSearchActive(false) - } - } - - const handleSearchChange = (value: string): void => { - setSearchQuery(value) - } - - const handleSearchSubmit = (): void => { - if (searchQuery.trim() && wandControlRef.current) { - wandControlRef.current.onWandTrigger(searchQuery) - setSearchQuery('') - setIsSearchActive(false) - } - } - - const handleSearchCancel = (): void => { - setSearchQuery('') - setIsSearchActive(false) - } - - const isStreaming = wandControlRef.current?.isWandStreaming ?? false - - return ( -
-
- -
- {showWand && ( - <> - {!isSearchActive ? ( - - ) : ( -
- ) => - handleSearchChange(e.target.value) - } - onBlur={(e: React.FocusEvent) => { - const relatedTarget = e.relatedTarget as HTMLElement | null - if (relatedTarget?.closest('button')) return - handleSearchBlur() - }} - onKeyDown={(e: React.KeyboardEvent) => { - if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) { - handleSearchSubmit() - } else if (e.key === 'Escape') { - handleSearchCancel() - } - }} - disabled={isStreaming} - className={cn( - 'h-5 min-w-[80px] flex-1 text-[11px]', - isStreaming && 'text-muted-foreground' - )} - placeholder='Generate with AI...' - /> - -
- )} - - )} - {canonicalToggle && !isPreview && ( - - - - - -

- {canonicalToggle.mode === 'advanced' - ? 'Switch to selector' - : 'Switch to manual ID'} -

-
-
- )} -
-
-
{children(wandControlRef)}
-
- ) -} - -/** - * Props for the ToolInput component - */ -interface ToolInputProps { - /** Unique identifier for the block */ - blockId: string - /** Unique identifier for the sub-block */ - subBlockId: string - /** Whether component is in preview mode */ - isPreview?: boolean - /** Value to display in preview mode */ - previewValue?: any - /** Whether the input is disabled */ - disabled?: boolean - /** Allow expanding tools in preview mode */ - allowExpandInPreview?: boolean -} - -/** - * Represents a tool selected and configured in the workflow - * - * @remarks - * For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded. - * Everything else (title, schema, code) is loaded dynamically from the database. - * Legacy custom tools with inline schema/code are still supported for backwards compatibility. - */ -interface StoredTool { - /** Block type identifier */ - type: string - /** Display title for the tool (optional for new custom tool format) */ - title?: string - /** Direct tool ID for execution (optional for new custom tool format) */ - toolId?: string - /** Parameter values configured by the user (optional for new custom tool format) */ - params?: Record - /** Whether the tool details are expanded in UI */ - isExpanded?: boolean - /** Database ID for custom tools (new format - reference only) */ - customToolId?: string - /** Tool schema for custom tools (legacy format - inline) */ - schema?: any - /** Implementation code for custom tools (legacy format - inline) */ - code?: string - /** Selected operation for multi-operation tools */ - operation?: string - /** Tool usage control mode for LLM */ - usageControl?: 'auto' | 'force' | 'none' -} - -/** - * Resolves a custom tool reference to its full definition. - * - * @remarks - * Custom tools can be stored in two formats: - * 1. Reference-only (new): `{ customToolId: "...", usageControl: "auto" }` - loads from database - * 2. Inline (legacy): `{ schema: {...}, code: "..." }` - uses embedded definition - * - * @param storedTool - The stored tool reference containing either a customToolId or inline definition - * @param customToolsList - List of custom tools fetched from the database - * @returns The resolved custom tool with schema, code, and title, or `null` if not found - */ -function resolveCustomToolFromReference( - storedTool: StoredTool, - customToolsList: CustomToolDefinition[] -): { schema: any; code: string; title: string } | null { - // If the tool has a customToolId (new reference format), look it up - if (storedTool.customToolId) { - const customTool = customToolsList.find((t) => t.id === storedTool.customToolId) - if (customTool) { - return { - schema: customTool.schema, - code: customTool.code, - title: customTool.title, - } - } - // If not found by ID, fall through to try other methods - logger.warn(`Custom tool not found by ID: ${storedTool.customToolId}`) - } - - // Legacy format: inline schema and code - if (storedTool.schema && storedTool.code !== undefined) { - return { - schema: storedTool.schema, - code: storedTool.code, - title: storedTool.title || '', - } - } - - return null -} - /** * Renders the input for workflow_executor's inputMapping parameter. * This is a special case that doesn't map to any SubBlockConfig, so it's kept here. @@ -525,6 +255,66 @@ function WorkflowToolDeployBadge({ ) } +/** + * Props for the ToolInput component + */ +interface ToolInputProps { + /** Unique identifier for the block */ + blockId: string + /** Unique identifier for the sub-block */ + subBlockId: string + /** Whether component is in preview mode */ + isPreview?: boolean + /** Value to display in preview mode */ + previewValue?: any + /** Whether the input is disabled */ + disabled?: boolean + /** Allow expanding tools in preview mode */ + allowExpandInPreview?: boolean +} + +/** + * Resolves a custom tool reference to its full definition. + * + * @remarks + * Custom tools can be stored in two formats: + * 1. Reference-only (new): `{ customToolId: "...", usageControl: "auto" }` - loads from database + * 2. Inline (legacy): `{ schema: {...}, code: "..." }` - uses embedded definition + * + * @param storedTool - The stored tool reference containing either a customToolId or inline definition + * @param customToolsList - List of custom tools fetched from the database + * @returns The resolved custom tool with schema, code, and title, or `null` if not found + */ +function resolveCustomToolFromReference( + storedTool: StoredTool, + customToolsList: CustomToolDefinition[] +): { schema: any; code: string; title: string } | null { + // If the tool has a customToolId (new reference format), look it up + if (storedTool.customToolId) { + const customTool = customToolsList.find((t) => t.id === storedTool.customToolId) + if (customTool) { + return { + schema: customTool.schema, + code: customTool.code, + title: customTool.title, + } + } + // If not found by ID, fall through to try other methods + logger.warn(`Custom tool not found by ID: ${storedTool.customToolId}`) + } + + // Legacy format: inline schema and code + if (storedTool.schema && storedTool.code !== undefined) { + return { + schema: storedTool.schema, + code: storedTool.code, + title: storedTool.title || '', + } + } + + return null +} + /** * Set of built-in tool types that are core platform tools. * @@ -557,7 +347,10 @@ const BUILT_IN_TOOL_TYPES = new Set([ * @param IconComponent - The Lucide icon component to render * @returns A styled div containing the icon with consistent dimensions */ -function createToolIcon(bgColor: string, IconComponent: any) { +function createToolIcon( + bgColor: string, + IconComponent: React.ComponentType<{ className?: string }> +) { return (
st.serverId === serverId && st.toolName === toolName) // Use DB schema if available, otherwise use Zustand schema - const schema = storedTool?.schema ?? tool.schema + const schema = storedTool?.schema ?? (tool.schema as McpToolSchema | undefined) return validateMcpTool( { @@ -825,40 +613,6 @@ export const ToolInput = memo(function ToolInput({ return selectedTools.some((tool) => tool.toolId === toolId) } - /** - * Checks if an MCP tool is already selected. - * - * @param mcpToolId - The MCP tool identifier to check - * @returns `true` if the MCP tool is already selected - */ - const isMcpToolAlreadySelected = (mcpToolId: string): boolean => { - return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId) - } - - /** - * Checks if a custom tool is already selected. - * - * @param customToolId - The custom tool identifier to check - * @returns `true` if the custom tool is already selected - */ - const isCustomToolAlreadySelected = (customToolId: string): boolean => { - return selectedTools.some( - (tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId - ) - } - - /** - * Checks if a workflow is already selected. - * - * @param workflowId - The workflow identifier to check - * @returns `true` if the workflow is already selected - */ - const isWorkflowAlreadySelected = (workflowId: string): boolean => { - return selectedTools.some( - (tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId - ) - } - /** * Checks if a block supports multiple operations. * @@ -1112,7 +866,7 @@ export const ToolInput = memo(function ToolInput({ customTools.some( (customTool) => customTool.id === toolId && - customTool.schema?.function?.name === tool.schema.function.name + customTool.schema?.function?.name === tool.schema?.function?.name ) ) { return false @@ -1168,10 +922,6 @@ export const ToolInput = memo(function ToolInput({ return } - const initialParams: Record = {} - - const oldToolParams = tool.toolId ? getToolParametersConfig(tool.toolId, tool.type) : null - const oldParamIds = new Set(oldToolParams?.userInputParameters.map((p) => p.id) || []) const newParamIds = new Set(toolParams.userInputParameters.map((p) => p.id)) const preservedParams: Record = {} @@ -1197,7 +947,7 @@ export const ToolInput = memo(function ToolInput({ ...tool, toolId: newToolId, operation, - params: { ...initialParams, ...preservedParams }, // Preserve all compatible existing values + params: preservedParams, } : tool ) @@ -1298,230 +1048,20 @@ export const ToolInput = memo(function ToolInput({ setDragOverIndex(null) } - const IconComponent = ({ icon: Icon, className }: { icon: any; className?: string }) => { + const IconComponent = ({ + icon: Icon, + className, + }: { + icon?: React.ComponentType<{ className?: string }> + className?: string + }) => { if (!Icon) return null return } - /** - * Generates grouped options for the tool selection combobox. - * - * @remarks - * Groups tools into categories: Actions (create/add), Custom Tools, - * MCP Tools, Built-in Tools, and Integrations. - * - * @returns Array of option groups for the combobox component - */ - const toolGroups = useMemo((): ComboboxOptionGroup[] => { - const groups: ComboboxOptionGroup[] = [] - - // Actions group (no section header) - const actionItems: ComboboxOption[] = [] - if (!permissionConfig.disableCustomTools) { - actionItems.push({ - label: 'Create Tool', - value: 'action-create-tool', - icon: WrenchIcon, - onSelect: () => { - setCustomToolModalOpen(true) - setOpen(false) - }, - disabled: isPreview, - }) - } - if (!permissionConfig.disableMcpTools) { - actionItems.push({ - label: 'Add MCP Server', - value: 'action-add-mcp', - icon: McpIcon, - onSelect: () => { - setOpen(false) - window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'mcp' } })) - }, - disabled: isPreview, - }) - } - if (actionItems.length > 0) { - groups.push({ items: actionItems }) - } - - // Custom Tools section - if (!permissionConfig.disableCustomTools && customTools.length > 0) { - groups.push({ - section: 'Custom Tools', - items: customTools.map((customTool) => { - const alreadySelected = isCustomToolAlreadySelected(customTool.id) - return { - label: customTool.title, - value: `custom-${customTool.id}`, - iconElement: createToolIcon('#3B82F6', WrenchIcon), - disabled: isPreview || alreadySelected, - onSelect: () => { - if (alreadySelected) return - const newTool: StoredTool = { - type: 'custom-tool', - customToolId: customTool.id, - usageControl: 'auto', - isExpanded: true, - } - setStoreValue([ - ...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), - newTool, - ]) - setOpen(false) - }, - } - }), - }) - } - - // MCP Tools section - if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) { - groups.push({ - section: 'MCP Tools', - items: availableMcpTools.map((mcpTool) => { - const server = mcpServers.find((s) => s.id === mcpTool.serverId) - const alreadySelected = isMcpToolAlreadySelected(mcpTool.id) - return { - label: mcpTool.name, - value: `mcp-${mcpTool.id}`, - iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), - onSelect: () => { - if (alreadySelected) return - const newTool: StoredTool = { - type: 'mcp', - title: mcpTool.name, - toolId: mcpTool.id, - params: { - serverId: mcpTool.serverId, - ...(server?.url && { serverUrl: server.url }), - toolName: mcpTool.name, - serverName: mcpTool.serverName, - }, - isExpanded: true, - usageControl: 'auto', - schema: { - ...mcpTool.inputSchema, - description: mcpTool.description, - }, - } - handleMcpToolSelect(newTool, true) - }, - disabled: isPreview || disabled || alreadySelected, - } - }), - }) - } - - // Split tool blocks into built-in tools and integrations - const builtInTools = toolBlocks.filter((block) => BUILT_IN_TOOL_TYPES.has(block.type)) - const integrations = toolBlocks.filter((block) => !BUILT_IN_TOOL_TYPES.has(block.type)) - - // Built-in Tools section - if (builtInTools.length > 0) { - groups.push({ - section: 'Built-in Tools', - items: builtInTools.map((block) => { - const toolId = getToolIdForOperation(block.type, undefined) - const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false - return { - label: block.name, - value: `builtin-${block.type}`, - iconElement: createToolIcon(block.bgColor, block.icon), - disabled: isPreview || alreadySelected, - onSelect: () => handleSelectTool(block), - } - }), - }) - } - - // Integrations section - if (integrations.length > 0) { - groups.push({ - section: 'Integrations', - items: integrations.map((block) => { - const toolId = getToolIdForOperation(block.type, undefined) - const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false - return { - label: block.name, - value: `builtin-${block.type}`, - iconElement: createToolIcon(block.bgColor, block.icon), - disabled: isPreview || alreadySelected, - onSelect: () => handleSelectTool(block), - } - }), - }) - } - - // Workflows section - shows available workflows that can be executed as tools - if (availableWorkflows.length > 0) { - groups.push({ - section: 'Workflows', - items: availableWorkflows.map((workflow) => { - const alreadySelected = isWorkflowAlreadySelected(workflow.id) - return { - label: workflow.name, - value: `workflow-${workflow.id}`, - iconElement: createToolIcon('#6366F1', WorkflowIcon), - onSelect: () => { - if (alreadySelected) return - const newTool: StoredTool = { - type: 'workflow_input', - title: 'Workflow', - toolId: 'workflow_executor', - params: { - workflowId: workflow.id, - }, - isExpanded: true, - usageControl: 'auto', - } - setStoreValue([ - ...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), - newTool, - ]) - setOpen(false) - }, - disabled: isPreview || disabled || alreadySelected, - } - }), - }) - } - - return groups - }, [ - customTools, - availableMcpTools, - mcpServers, - toolBlocks, - isPreview, - disabled, - selectedTools, - setStoreValue, - handleMcpToolSelect, - handleSelectTool, - permissionConfig.disableCustomTools, - permissionConfig.disableMcpTools, - availableWorkflows, - getToolIdForOperation, - isToolAlreadySelected, - isMcpToolAlreadySelected, - isCustomToolAlreadySelected, - isWorkflowAlreadySelected, - ]) - - const toolRequiresOAuth = (toolId: string): boolean => { - const toolParams = getToolParametersConfig(toolId) - return toolParams?.toolConfig?.oauth?.required || false - } - - const getToolOAuthConfig = (toolId: string) => { - const toolParams = getToolParametersConfig(toolId) - return toolParams?.toolConfig?.oauth - } - - const evaluateParameterCondition = (param: any, tool: StoredTool): boolean => { + const evaluateParameterCondition = (param: ToolParameterConfig, tool: StoredTool): boolean => { if (!('uiComponent' in param) || !param.uiComponent?.condition) return true - const currentValues: Record = { operation: tool.operation, ...tool.params } + const currentValues: Record = { operation: tool.operation, ...tool.params } return evaluateSubBlockCondition( param.uiComponent.condition as SubBlockCondition, currentValues @@ -1682,6 +1222,219 @@ export const ToolInput = memo(function ToolInput({ } } + /** + * Generates grouped options for the tool selection combobox. + * + * @remarks + * Groups tools into categories: Actions (create/add), Custom Tools, + * MCP Tools, Built-in Tools, and Integrations. + * + * @returns Array of option groups for the combobox component + */ + const toolGroups = useMemo((): ComboboxOptionGroup[] => { + const groups: ComboboxOptionGroup[] = [] + + // Actions group (no section header) + const actionItems: ComboboxOption[] = [] + if (!permissionConfig.disableCustomTools) { + actionItems.push({ + label: 'Create Tool', + value: 'action-create-tool', + icon: WrenchIcon, + onSelect: () => { + setCustomToolModalOpen(true) + setOpen(false) + }, + disabled: isPreview, + }) + } + if (!permissionConfig.disableMcpTools) { + actionItems.push({ + label: 'Add MCP Server', + value: 'action-add-mcp', + icon: McpIcon, + onSelect: () => { + setOpen(false) + window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'mcp' } })) + }, + disabled: isPreview, + }) + } + if (actionItems.length > 0) { + groups.push({ items: actionItems }) + } + + // Custom Tools section + if (!permissionConfig.disableCustomTools && customTools.length > 0) { + groups.push({ + section: 'Custom Tools', + items: customTools.map((customTool) => { + const alreadySelected = isCustomToolAlreadySelected(selectedTools, customTool.id) + return { + label: customTool.title, + value: `custom-${customTool.id}`, + iconElement: createToolIcon('#3B82F6', WrenchIcon), + disabled: isPreview || alreadySelected, + onSelect: () => { + if (alreadySelected) return + const newTool: StoredTool = { + type: 'custom-tool', + customToolId: customTool.id, + usageControl: 'auto', + isExpanded: true, + } + setStoreValue([ + ...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), + newTool, + ]) + setOpen(false) + }, + } + }), + }) + } + + // MCP Tools section + if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) { + groups.push({ + section: 'MCP Tools', + items: availableMcpTools.map((mcpTool) => { + const server = mcpServers.find((s) => s.id === mcpTool.serverId) + const alreadySelected = isMcpToolAlreadySelected(selectedTools, mcpTool.id) + return { + label: mcpTool.name, + value: `mcp-${mcpTool.id}`, + iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), + onSelect: () => { + if (alreadySelected) return + const newTool: StoredTool = { + type: 'mcp', + title: mcpTool.name, + toolId: mcpTool.id, + params: { + serverId: mcpTool.serverId, + ...(server?.url && { serverUrl: server.url }), + toolName: mcpTool.name, + serverName: mcpTool.serverName, + }, + isExpanded: true, + usageControl: 'auto', + schema: { + ...mcpTool.inputSchema, + description: mcpTool.description, + }, + } + handleMcpToolSelect(newTool, true) + }, + disabled: isPreview || disabled || alreadySelected, + } + }), + }) + } + + // Split tool blocks into built-in tools and integrations + const builtInTools = toolBlocks.filter((block) => BUILT_IN_TOOL_TYPES.has(block.type)) + const integrations = toolBlocks.filter((block) => !BUILT_IN_TOOL_TYPES.has(block.type)) + + // Built-in Tools section + if (builtInTools.length > 0) { + groups.push({ + section: 'Built-in Tools', + items: builtInTools.map((block) => { + const toolId = getToolIdForOperation(block.type, undefined) + const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false + return { + label: block.name, + value: `builtin-${block.type}`, + iconElement: createToolIcon(block.bgColor, block.icon), + disabled: isPreview || alreadySelected, + onSelect: () => handleSelectTool(block), + } + }), + }) + } + + // Integrations section + if (integrations.length > 0) { + groups.push({ + section: 'Integrations', + items: integrations.map((block) => { + const toolId = getToolIdForOperation(block.type, undefined) + const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false + return { + label: block.name, + value: `builtin-${block.type}`, + iconElement: createToolIcon(block.bgColor, block.icon), + disabled: isPreview || alreadySelected, + onSelect: () => handleSelectTool(block), + } + }), + }) + } + + // Workflows section - shows available workflows that can be executed as tools + if (availableWorkflows.length > 0) { + groups.push({ + section: 'Workflows', + items: availableWorkflows.map((workflow) => { + const alreadySelected = isWorkflowAlreadySelected(selectedTools, workflow.id) + return { + label: workflow.name, + value: `workflow-${workflow.id}`, + iconElement: createToolIcon('#6366F1', WorkflowIcon), + onSelect: () => { + if (alreadySelected) return + const newTool: StoredTool = { + type: 'workflow_input', + title: 'Workflow', + toolId: 'workflow_executor', + params: { + workflowId: workflow.id, + }, + isExpanded: true, + usageControl: 'auto', + } + setStoreValue([ + ...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), + newTool, + ]) + setOpen(false) + }, + disabled: isPreview || disabled || alreadySelected, + } + }), + }) + } + + return groups + }, [ + customTools, + availableMcpTools, + mcpServers, + toolBlocks, + isPreview, + disabled, + selectedTools, + setStoreValue, + handleMcpToolSelect, + handleSelectTool, + permissionConfig.disableCustomTools, + permissionConfig.disableMcpTools, + availableWorkflows, + getToolIdForOperation, + isToolAlreadySelected, + ]) + + const toolRequiresOAuth = (toolId: string): boolean => { + const toolParams = getToolParametersConfig(toolId) + return toolParams?.toolConfig?.oauth?.required || false + } + + const getToolOAuthConfig = (toolId: string) => { + const toolParams = getToolParametersConfig(toolId) + return toolParams?.toolConfig?.oauth + } + return (
{/* Add Tool Combobox - always at top */} @@ -1815,14 +1568,10 @@ export const ToolInput = memo(function ToolInput({ // Determine if tool has expandable body content const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type) - // For subblock-based rendering, conditions are already evaluated in getSubBlocksForToolInput - const filteredDisplayParams = useSubBlocks - ? displayParams // unused when useSubBlocks, but needed for type consistency - : displayParams.filter((param) => evaluateParameterCondition(param, tool)) - const hasToolBody = - hasOperations || - (requiresOAuth && oauthConfig) || - (useSubBlocks ? displaySubBlocks.length > 0 : filteredDisplayParams.length > 0) + const hasParams = useSubBlocks + ? displaySubBlocks.length > 0 + : displayParams.filter((param) => evaluateParameterCondition(param, tool)).length > 0 + const hasToolBody = hasOperations || (requiresOAuth && oauthConfig) || hasParams // Only show expansion if tool has body content const isExpandedForDisplay = hasToolBody @@ -2030,7 +1779,9 @@ export const ToolInput = memo(function ToolInput({
handleParamChange(toolIndex, 'credential', value)} + onChange={(value: string) => + handleParamChange(toolIndex, 'credential', value) + } provider={oauthConfig.provider as OAuthProvider} requiredScopes={ toolBlock?.subBlocks?.find((sb) => sb.id === 'credential') @@ -2051,12 +1802,6 @@ export const ToolInput = memo(function ToolInput({ if (useSubBlocks && displaySubBlocks.length > 0) { return displaySubBlocks.map((sb) => { const effectiveParamId = sb.canonicalParamId || sb.id - const visibility = - sb.paramVisibility || - toolParams?.allParameters?.find((p) => p.id === effectiveParamId) - ?.visibility || - 'user-or-llm' - const isRequired = sb.required === true // Compute canonical toggle for basic/advanced mode switching const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id] @@ -2077,7 +1822,6 @@ export const ToolInput = memo(function ToolInput({ hasCanonicalPair && canonicalMode && canonicalId ? { mode: canonicalMode, - disabled: disabled, onToggle: () => { const nextMode = canonicalMode === 'advanced' ? 'basic' : 'advanced' @@ -2086,33 +1830,24 @@ export const ToolInput = memo(function ToolInput({ } : undefined + // Ensure title is present for SubBlock's label rendering + const sbWithTitle = sb.title + ? sb + : { ...sb, title: formatParameterLabel(effectiveParamId) } + return ( - - {(wandControlRef) => ( - - )} - + canonicalToggle={canonicalToggleProp} + /> ) }) } @@ -2137,35 +1872,14 @@ export const ToolInput = memo(function ToolInput({ disabled={disabled} isPreview={isPreview || false} > - {(wandControlRef) => - param.uiComponent ? ( - renderParameterInput( - param, - tool.params?.[param.id] || '', - (value) => handleParamChange(toolIndex, param.id, value), - toolIndex, - toolContextValues as Record, - wandControlRef - ) - ) : ( - handleParamChange(toolIndex, param.id, value)} - wandControlRef={wandControlRef} - hideInternalWand={true} - /> + {(wandControlRef: React.MutableRefObject) => + renderParameterInput( + param, + tool.params?.[param.id] || '', + (value) => handleParamChange(toolIndex, param.id, value), + toolIndex, + toolContextValues as Record, + wandControlRef ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts new file mode 100644 index 000000000..138b6a562 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts @@ -0,0 +1,31 @@ +/** + * Represents a tool selected and configured in the workflow + * + * @remarks + * For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded. + * Everything else (title, schema, code) is loaded dynamically from the database. + * Legacy custom tools with inline schema/code are still supported for backwards compatibility. + */ +export interface StoredTool { + /** Block type identifier */ + type: string + /** Display title for the tool (optional for new custom tool format) */ + title?: string + /** Direct tool ID for execution (optional for new custom tool format) */ + toolId?: string + /** Parameter values configured by the user (optional for new custom tool format) */ + params?: Record + /** Whether the tool details are expanded in UI */ + isExpanded?: boolean + /** Database ID for custom tools (new format - reference only) */ + customToolId?: string + /** Tool schema for custom tools (legacy format - inline JSON schema) */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schema?: Record + /** Implementation code for custom tools (legacy format - inline) */ + code?: string + /** Selected operation for multi-operation tools */ + operation?: string + /** Tool usage control mode for LLM */ + usageControl?: 'auto' | 'force' | 'none' +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils.ts new file mode 100644 index 000000000..1110a5808 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils.ts @@ -0,0 +1,32 @@ +import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types' + +/** + * Checks if an MCP tool is already selected. + */ +export function isMcpToolAlreadySelected(selectedTools: StoredTool[], mcpToolId: string): boolean { + return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId) +} + +/** + * Checks if a custom tool is already selected. + */ +export function isCustomToolAlreadySelected( + selectedTools: StoredTool[], + customToolId: string +): boolean { + return selectedTools.some( + (tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId + ) +} + +/** + * Checks if a workflow is already selected. + */ +export function isWorkflowAlreadySelected( + selectedTools: StoredTool[], + workflowId: string +): boolean { + return selectedTools.some( + (tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index c8422f0e7..edf99ce01 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -76,6 +76,7 @@ interface SubBlockProps { disabled?: boolean onToggle?: () => void } + labelSuffix?: React.ReactNode } /** @@ -202,7 +203,8 @@ const renderLabel = ( showCopyButton: boolean copied: boolean onCopy: () => void - } + }, + labelSuffix?: React.ReactNode ): JSX.Element | null => { if (config.type === 'switch') return null if (!config.title) return null @@ -218,6 +220,7 @@ const renderLabel = (
diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 8729acb84..89a9d0f8d 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -907,8 +907,7 @@ export function getSubBlocksForToolInput( parts.length >= 3 ? parts.slice(2).join('_') : parts[parts.length - 1] } - // Build a set of param IDs from the tool config for fallback visibility inference - const toolParamIds = new Set(Object.keys(toolConfig.params || {})) + // Build a map of tool param IDs to their resolved visibility const toolParamVisibility: Record = {} for (const [paramId, param] of Object.entries(toolConfig.params || {})) { toolParamVisibility[paramId] = @@ -945,16 +944,21 @@ export function getSubBlocksForToolInput( // Auth tokens without explicit paramVisibility are hidden // (they're handled by the OAuth credential selector or structurally) // But only if they don't have a matching tool param - if (!toolParamIds.has(sb.id)) { + if (!(sb.id in toolParamVisibility)) { visibility = 'hidden' } else { visibility = toolParamVisibility[sb.id] || 'user-or-llm' } - } else if (toolParamIds.has(effectiveParamId)) { + } else if (effectiveParamId in toolParamVisibility) { // Fallback: infer from tool param visibility visibility = toolParamVisibility[effectiveParamId] - } else if (toolParamIds.has(sb.id)) { + } else if (sb.id in toolParamVisibility) { visibility = toolParamVisibility[sb.id] + } else if (sb.canonicalParamId) { + // SubBlock has a canonicalParamId that doesn't directly match a tool param. + // This means the block's params() function transforms it before sending to the tool + // (e.g. listFolderId → folderId). These are user-facing inputs, default to user-or-llm. + visibility = 'user-or-llm' } else { // SubBlock has no corresponding tool param — skip it continue @@ -987,16 +991,16 @@ export function getSubBlocksForToolInput( // Find the advanced variant const advancedSb = allSubBlocks.find((s) => group.advancedIds.includes(s.id)) if (advancedSb) { - filtered.push(advancedSb) + filtered.push({ ...advancedSb, paramVisibility: visibility }) } } else { // Include basic variant (current sb if it's the basic one) if (group.basicId === sb.id) { - filtered.push(sb) + filtered.push({ ...sb, paramVisibility: visibility }) } else { const basicSb = allSubBlocks.find((s) => s.id === group.basicId) if (basicSb) { - filtered.push(basicSb) + filtered.push({ ...basicSb, paramVisibility: visibility }) } } } @@ -1005,7 +1009,7 @@ export function getSubBlocksForToolInput( } // Non-canonical, non-hidden, condition-passing subblock - filtered.push(sb) + filtered.push({ ...sb, paramVisibility: visibility }) } return {