From 7210330f75f0cf09dfd4759cf3fdb312de4524e8 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 9 Feb 2026 09:18:45 -0800 Subject: [PATCH] refactor(tool-input): eliminate SyncWrappers, add canonical toggle and dependsOn gating Replace 17+ individual SyncWrapper components with a single centralized ToolSubBlockRenderer that bridges the subblock store with StoredTool.params via synthetic store keys. This reduces ~1000 lines of duplicated wrapper code and ensures tool-input renders subblock components identically to the standalone SubBlock path. - Add ToolSubBlockRenderer with bidirectional store sync - Add basic/advanced mode toggle (ArrowLeftRight) using collaborative functions - Add dependsOn gating via useDependsOnGate (fields disable instead of hiding) - Add paramVisibility field to SubBlockConfig for tool-input visibility control - Pass canonicalModeOverrides through getSubBlocksForToolInput - Show (optional) label for non-user-only fields (LLM can inject at runtime) Co-Authored-By: Claude Opus 4.6 --- .../components/tool-sub-block-renderer.tsx | 410 +++++ .../components/tool-input/tool-input.tsx | 1377 +++++------------ apps/sim/blocks/types.ts | 2 + apps/sim/tools/params-resolver.ts | 4 + apps/sim/tools/params.ts | 210 ++- 5 files changed, 994 insertions(+), 1009 deletions(-) create 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 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 new file mode 100644 index 0000000000..bb8c26f9c5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-sub-block-renderer.tsx @@ -0,0 +1,410 @@ +'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/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 8f03f4b2e5..153178deeb 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,13 +1,16 @@ import type React from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { Loader2, WrenchIcon, XIcon } from 'lucide-react' +import { ArrowLeftRight, ArrowUp, Loader2, WrenchIcon, XIcon } from 'lucide-react' import { useParams } from 'next/navigation' import { Badge, + Button, Combobox, type ComboboxOption, type ComboboxOptionGroup, + Input, + Label, Popover, PopoverContent, PopoverItem, @@ -32,31 +35,19 @@ import { import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { - CheckboxList, - Code, - FileSelectorInput, - FileUpload, - FolderSelectorInput, LongInput, - ProjectSelectorInput, - SheetSelectorInput, ShortInput, - SlackSelectorInput, - SliderInput, - Table, - TimeInput, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components' -import { DocumentSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-selector/document-selector' -import { DocumentTagEntry } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry' -import { KnowledgeBaseSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector' -import { KnowledgeTagFilters } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters' 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 { 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' +import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' import { useMcpTools } from '@/hooks/mcp/use-mcp-tools' import { type CustomTool as CustomToolDefinition, @@ -69,26 +60,217 @@ import { useWorkflowState, useWorkflows, } from '@/hooks/queries/workflows' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePermissionConfig } from '@/hooks/use-permission-config' import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils' import { useSettingsModalStore } from '@/stores/modals/settings/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { formatParameterLabel, + getSubBlocksForToolInput, getToolParametersConfig, isPasswordParameter, + type SubBlocksForToolInput, type ToolParameterConfig, } from '@/tools/params' import { buildCanonicalIndex, buildPreviewContextValues, type CanonicalIndex, + type CanonicalModeOverrides, evaluateSubBlockCondition, + isCanonicalPair, + resolveCanonicalMode, type SubBlockCondition, } from '@/tools/params-resolver' 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 */ @@ -161,590 +343,30 @@ function resolveCustomToolFromReference( 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 -} - -/** - * Generic sync wrapper that synchronizes store values with local component state. - * - * @remarks - * Used to sync tool parameter values between the workflow store and local controlled inputs. - * Listens for changes in the store and propagates them to the local component via onChange. - * - * @typeParam T - The type of the store value being synchronized - * - * @param blockId - The block identifier for store lookup - * @param paramId - The parameter identifier within the block - * @param value - Current local value - * @param onChange - Callback to update the local value - * @param children - Child components to render - * @param transformer - Optional function to transform store value before comparison - * @returns The children wrapped with synchronization logic - */ -function GenericSyncWrapper({ - blockId, - paramId, - value, - onChange, - children, - transformer, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - children: React.ReactNode - transformer?: (storeValue: T) => string -}) { - const [storeValue] = useSubBlockValue(blockId, paramId) - - useEffect(() => { - if (storeValue != null) { - const transformedValue = transformer ? transformer(storeValue) : String(storeValue) - if (transformedValue !== value) { - onChange(transformedValue) - } - } - }, [storeValue, value, onChange, transformer]) - - return <>{children} -} - -function FileSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function SheetSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function FolderSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function KnowledgeBaseSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - - - - ) -} - -function DocumentSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function DocumentTagEntrySyncWrapper({ - blockId, - paramId, - value, - onChange, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function KnowledgeTagFiltersSyncWrapper({ - blockId, - paramId, - value, - onChange, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function TableSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - JSON.stringify(storeValue)} - > -
- - ) -} - -function TimeInputSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - - - - ) -} - -function SliderInputSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - String(storeValue)} - > - - - ) -} - -function CheckboxListSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - JSON.stringify(storeValue)} - > - - - ) -} - -function ComboboxSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - const options = (uiComponent.options || []).map((opt: any) => - typeof opt === 'string' ? { label: opt, value: opt } : { label: opt.label, value: opt.id } - ) - - return ( - - - - ) -} - -function FileUploadSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - JSON.stringify(storeValue)} - > - - - ) -} - -function SlackSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - previewContextValues, - selectorType, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - previewContextValues?: Record - selectorType: 'channel-selector' | 'user-selector' -}) { - return ( - - - - ) -} - -function WorkflowSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - workspaceId, - currentWorkflowId, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - workspaceId: string - currentWorkflowId?: string -}) { - const { data: workflows = [], isLoading } = useWorkflows(workspaceId, { syncRegistry: false }) - - const availableWorkflows = workflows.filter( - (w) => !currentWorkflowId || w.id !== currentWorkflowId - ) + title: customTool.title, + } + } + // If not found by ID, fall through to try other methods + logger.warn(`Custom tool not found by ID: ${storedTool.customToolId}`) + } - const options = availableWorkflows.map((workflow) => ({ - label: workflow.name, - value: workflow.id, - })) + // Legacy format: inline schema and code + if (storedTool.schema && storedTool.code !== undefined) { + return { + schema: storedTool.schema, + code: storedTool.code, + title: storedTool.title || '', + } + } - return ( - - - - ) + return null } -function WorkflowInputMapperSyncWrapper({ +/** + * 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. + */ +function WorkflowInputMapperInput({ blockId, paramId, value, @@ -774,7 +396,7 @@ function WorkflowInputMapperSyncWrapper({ }, [value]) const handleFieldChange = useCallback( - (fieldName: string, fieldValue: any) => { + (fieldName: string, fieldValue: string) => { const newValue = { ...parsedValue, [fieldName]: fieldValue } onChange(JSON.stringify(newValue)) }, @@ -807,7 +429,7 @@ function WorkflowInputMapperSyncWrapper({ return (
- {inputFields.map((field: any) => ( + {inputFields.map((field: { name: string; type: string }) => ( void - disabled: boolean - uiComponent: any - currentToolParams?: Record -}) { - const language = (currentToolParams?.language as 'javascript' | 'python') || 'javascript' - - return ( - - - - ) -} - /** * Badge component showing deployment status for workflow tools */ @@ -1009,6 +593,14 @@ export const ToolInput = memo(function ToolInput({ const [dragOverIndex, setDragOverIndex] = useState(null) const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState(null) + const canonicalModeOverrides = useWorkflowStore( + useCallback( + (state) => state.blocks[blockId]?.data?.canonicalModes as CanonicalModeOverrides | undefined, + [blockId] + ) + ) + const { collaborativeSetBlockCanonicalMode } = useCollaborativeWorkflow() + const value = isPreview ? previewValue : storeValue const selectedTools: StoredTool[] = @@ -1352,26 +944,6 @@ export const ToolInput = memo(function ToolInput({ return block.tools.access[0] } - /** - * Initializes tool parameters with empty values. - * - * @remarks - * Returns an empty object as parameters are populated dynamically - * based on user input and default values from the tool configuration. - * - * @param toolId - The tool identifier - * @param params - Array of parameter configurations - * @param instanceId - Optional instance identifier for unique param keys - * @returns Empty parameter object to be populated by the user - */ - const initializeToolParams = ( - toolId: string, - params: ToolParameterConfig[], - instanceId?: string - ): Record => { - return {} - } - const handleSelectTool = useCallback( (toolBlock: (typeof toolBlocks)[0]) => { if (isPreview || disabled) return @@ -1388,7 +960,7 @@ export const ToolInput = memo(function ToolInput({ const toolParams = getToolParametersConfig(toolId, toolBlock.type) if (!toolParams) return - const initialParams = initializeToolParams(toolId, toolParams.userInputParameters, blockId) + const initialParams: Record = {} toolParams.userInputParameters.forEach((param) => { if (param.uiComponent?.value && !initialParams[param.id]) { @@ -1421,7 +993,6 @@ export const ToolInput = memo(function ToolInput({ getOperationOptions, getToolIdForOperation, isToolAlreadySelected, - initializeToolParams, blockId, selectedTools, setStoreValue, @@ -1591,7 +1162,7 @@ export const ToolInput = memo(function ToolInput({ return } - const initialParams = initializeToolParams(newToolId, toolParams.userInputParameters, blockId) + const initialParams: Record = {} const oldToolParams = tool.toolId ? getToolParametersConfig(tool.toolId, tool.type) : null const oldParamIds = new Set(oldToolParams?.userInputParameters.map((p) => p.id) || []) @@ -1626,15 +1197,7 @@ export const ToolInput = memo(function ToolInput({ ) ) }, - [ - isPreview, - disabled, - selectedTools, - getToolIdForOperation, - initializeToolParams, - blockId, - setStoreValue, - ] + [isPreview, disabled, selectedTools, getToolIdForOperation, blockId, setStoreValue] ) const handleUsageControlChange = useCallback( @@ -1960,26 +1523,18 @@ export const ToolInput = memo(function ToolInput({ } /** - * Renders the appropriate UI component for a tool parameter. - * - * @remarks - * Supports multiple input types including dropdown, switch, long-input, - * short-input, file-selector, table, slider, and more. Falls back to - * ShortInput for unknown types. + * Renders a parameter input for custom tools, MCP tools, and legacy registry + * tools that don't have SubBlockConfig definitions. * - * @param param - The parameter configuration defining the input type - * @param value - The current parameter value - * @param onChange - Callback to handle value changes - * @param toolIndex - Index of the tool in the selected tools array - * @param currentToolParams - Current values of all tool parameters for dependencies - * @returns JSX element for the parameter input component + * Registry tools with subBlocks use ToolSubBlockRenderer instead. */ const renderParameterInput = ( param: ToolParameterConfig, value: string, onChange: (value: string) => void, toolIndex?: number, - currentToolParams?: Record + currentToolParams?: Record, + wandControlRef?: React.MutableRefObject ) => { const uniqueSubBlockId = toolIndex !== undefined @@ -2001,6 +1556,8 @@ export const ToolInput = memo(function ToolInput({ }} value={value} onChange={onChange} + wandControlRef={wandControlRef} + hideInternalWand={true} /> ) } @@ -2010,11 +1567,11 @@ export const ToolInput = memo(function ToolInput({ return ( option.id !== '') - .map((option: any) => ({ + (uiComponent.options as { id?: string; label: string; value?: string }[] | undefined) + ?.filter((option) => (option.id ?? option.value) !== '') + .map((option) => ({ label: option.label, - value: option.id, + value: option.id ?? option.value ?? '', })) || [] } value={value} @@ -2042,9 +1599,12 @@ export const ToolInput = memo(function ToolInput({ id: uniqueSubBlockId, type: 'long-input', title: param.id, + wandConfig: uiComponent.wandConfig, }} value={value} onChange={onChange} + wandControlRef={wandControlRef} + hideInternalWand={true} /> ) @@ -2059,58 +1619,13 @@ export const ToolInput = memo(function ToolInput({ id: uniqueSubBlockId, type: 'short-input', title: param.id, + wandConfig: uiComponent.wandConfig, }} value={value} onChange={onChange} disabled={disabled} - /> - ) - - case 'channel-selector': - return ( - - ) - - case 'user-selector': - return ( - - ) - - case 'project-selector': - return ( - ) @@ -2126,135 +1641,10 @@ export const ToolInput = memo(function ToolInput({ /> ) - case 'file-selector': - return ( - - ) - - case 'sheet-selector': - return ( - - ) - - case 'folder-selector': - return ( - - ) - - case 'table': - return ( - - ) - - case 'combobox': - return ( - - ) - - case 'slider': - return ( - - ) - - case 'checkbox-list': - return ( - - ) - - case 'time-input': - return ( - - ) - - case 'file-upload': - return ( - - ) - - case 'workflow-selector': - return ( - - ) - case 'workflow-input-mapper': { const selectedWorkflowId = currentToolParams?.workflowId || '' return ( - - ) - - case 'knowledge-base-selector': - return ( - - ) - - case 'document-selector': - return ( - - ) - - case 'document-tag-entry': - return ( - - ) - - case 'knowledge-tag-filters': - return ( - - ) - default: return ( ) } @@ -2388,6 +1718,20 @@ export const ToolInput = memo(function ToolInput({ }) : null + // Get subblocks for tool-input (primary source for registry tools) + const subBlocksResult: SubBlocksForToolInput | null = + !isCustomTool && !isMcpTool && currentToolId + ? getSubBlocksForToolInput( + currentToolId, + tool.type, + { + operation: tool.operation, + ...tool.params, + }, + canonicalModeOverrides + ) + : null + // Build canonical index for proper dependency resolution const toolCanonicalIndex: CanonicalIndex | null = toolBlock?.subBlocks ? buildCanonicalIndex(toolBlock.subBlocks) @@ -2446,11 +1790,16 @@ export const ToolInput = memo(function ToolInput({ : [] // Get all parameters to display - const displayParams = isCustomTool + // For registry tools with subBlocks, use the subblock-first approach + const useSubBlocks = !isCustomTool && !isMcpTool && subBlocksResult?.subBlocks?.length + const displayParams: ToolParameterConfig[] = isCustomTool ? customToolParams : isMcpTool ? mcpToolParams : toolParams?.userInputParameters || [] + const displaySubBlocks: BlockSubBlockConfig[] = useSubBlocks + ? subBlocksResult!.subBlocks + : [] // Check if tool requires OAuth const requiresOAuth = @@ -2460,11 +1809,14 @@ export const ToolInput = memo(function ToolInput({ // Determine if tool has expandable body content const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type) - const filteredDisplayParams = displayParams.filter((param) => - evaluateParameterCondition(param, tool) - ) + // 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) || filteredDisplayParams.length > 0 + hasOperations || + (requiresOAuth && oauthConfig) || + (useSubBlocks ? displaySubBlocks.length > 0 : filteredDisplayParams.length > 0) // Only show expansion if tool has body content const isExpandedForDisplay = hasToolBody @@ -2689,96 +2041,105 @@ export const ToolInput = memo(function ToolInput({ {/* Tool parameters */} {(() => { + // SubBlock-first rendering for registry tools + 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] + const canonicalGroup = canonicalId + ? toolCanonicalIndex?.groupsById[canonicalId] + : undefined + const hasCanonicalPair = isCanonicalPair(canonicalGroup) + const canonicalMode = + canonicalGroup && hasCanonicalPair + ? resolveCanonicalMode( + canonicalGroup, + { operation: tool.operation, ...tool.params }, + canonicalModeOverrides + ) + : undefined + + const canonicalToggleProp = + hasCanonicalPair && canonicalMode && canonicalId + ? { + mode: canonicalMode, + disabled: disabled, + onToggle: () => { + const nextMode = + canonicalMode === 'advanced' ? 'basic' : 'advanced' + collaborativeSetBlockCanonicalMode(blockId, canonicalId, nextMode) + }, + } + : undefined + + return ( + + {(wandControlRef) => ( + + )} + + ) + }) + } + + // Fallback: legacy ToolParameterConfig-based rendering + // Used for custom tools, MCP tools, and registry tools without subBlocks const filteredParams = displayParams.filter((param) => evaluateParameterCondition(param, tool) ) - const groupedParams: { [key: string]: ToolParameterConfig[] } = {} - const standaloneParams: ToolParameterConfig[] = [] - - // Group checkbox-list parameters by their UI component title - filteredParams.forEach((param) => { - const paramConfig = param as ToolParameterConfig - if ( - paramConfig.uiComponent?.type === 'checkbox-list' && - paramConfig.uiComponent?.title - ) { - const groupKey = paramConfig.uiComponent.title - if (!groupedParams[groupKey]) { - groupedParams[groupKey] = [] - } - groupedParams[groupKey].push(paramConfig) - } else { - standaloneParams.push(paramConfig) - } - }) const renderedElements: React.ReactNode[] = [] - // Render grouped checkbox-lists - Object.entries(groupedParams).forEach(([groupTitle, params]) => { - const firstParam = params[0] as ToolParameterConfig - const groupValue = JSON.stringify( - params.reduce( - (acc, p) => ({ ...acc, [p.id]: tool.params?.[p.id] === 'true' }), - {} - ) - ) - - renderedElements.push( -
-
- {groupTitle} -
-
- { - try { - const parsed = JSON.parse(value) - params.forEach((param) => { - handleParamChange( - toolIndex, - param.id, - parsed[param.id] ? 'true' : 'false' - ) - }) - } catch (e) { - // Handle error - } - }} - uiComponent={firstParam.uiComponent} - disabled={disabled} - /> -
-
- ) - }) - - // Render standalone parameters - standaloneParams.forEach((param) => { + filteredParams.forEach((param) => { renderedElements.push( -
-
- {param.uiComponent?.title || formatParameterLabel(param.id)} - {param.required && param.visibility === 'user-only' && ( - * - )} - {param.visibility === 'user-or-llm' && ( - - (optional) - - )} -
-
- {param.uiComponent ? ( + + {(wandControlRef) => + param.uiComponent ? ( renderParameterInput( param, tool.params?.[param.id] || '', (value) => handleParamChange(toolIndex, param.id, value), toolIndex, - toolContextValues as Record + toolContextValues as Record, + wandControlRef ) ) : ( handleParamChange(toolIndex, param.id, value)} + wandControlRef={wandControlRef} + hideInternalWand={true} /> - )} -
-
+ ) + } + ) }) diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 08a716925f..8ac262bef5 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -196,6 +196,8 @@ export interface SubBlockConfig { type: SubBlockType mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode canonicalParamId?: string + /** Controls parameter visibility in agent/tool-input context */ + paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden' required?: | boolean | { diff --git a/apps/sim/tools/params-resolver.ts b/apps/sim/tools/params-resolver.ts index 641511af6f..d213372d64 100644 --- a/apps/sim/tools/params-resolver.ts +++ b/apps/sim/tools/params-resolver.ts @@ -1,6 +1,7 @@ import { buildCanonicalIndex, type CanonicalIndex, + type CanonicalModeOverrides, evaluateSubBlockCondition, getCanonicalValues, isCanonicalPair, @@ -12,7 +13,10 @@ import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' export { buildCanonicalIndex, type CanonicalIndex, + type CanonicalModeOverrides, evaluateSubBlockCondition, + isCanonicalPair, + resolveCanonicalMode, type SubBlockCondition, } diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 9ac5a9788b..1f18b1edf6 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -1,13 +1,17 @@ import { createLogger } from '@sim/logger' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { + buildCanonicalIndex, + type CanonicalModeOverrides, evaluateSubBlockCondition, + isCanonicalPair, + resolveCanonicalMode, type SubBlockCondition, } from '@/lib/workflows/subblocks/visibility' -import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' +import type { SubBlockConfig as BlockSubBlockConfig, GenerationType } from '@/blocks/types' import { safeAssign } from '@/tools/safe-assign' import { isEmptyTagValue } from '@/tools/shared/tags' -import type { ParameterVisibility, ToolConfig } from '@/tools/types' +import type { OAuthConfig, ParameterVisibility, ToolConfig } from '@/tools/types' import { getTool } from '@/tools/utils' const logger = createLogger('ToolsParams') @@ -64,6 +68,14 @@ export interface UIComponentConfig { mode?: 'basic' | 'advanced' | 'both' | 'trigger' /** The actual subblock ID this config was derived from */ actualSubBlockId?: string + /** Wand configuration for AI assistance */ + wandConfig?: { + enabled: boolean + prompt: string + generationType?: GenerationType + placeholder?: string + maintainHistory?: boolean + } } export interface SubBlockConfig { @@ -327,6 +339,7 @@ export function getToolParametersConfig( canonicalParamId: subBlock.canonicalParamId, mode: subBlock.mode, actualSubBlockId: subBlock.id, + wandConfig: subBlock.wandConfig, } } } @@ -811,3 +824,196 @@ export function formatParameterLabel(paramId: string): string { // Simple case - just capitalize first letter return paramId.charAt(0).toUpperCase() + paramId.slice(1) } + +/** + * SubBlock IDs that are "structural" — they control tool routing or auth, + * not user-facing parameters. These are excluded from tool-input rendering + * unless they have an explicit paramVisibility set. + */ +const STRUCTURAL_SUBBLOCK_IDS = new Set(['operation', 'authMethod', 'destinationType']) + +/** + * SubBlock types that represent auth/credential inputs handled separately + * by the tool-input OAuth credential selector. + */ +const AUTH_SUBBLOCK_TYPES = new Set(['oauth-input']) + +/** + * SubBlock types that should never appear in tool-input context. + */ +const EXCLUDED_SUBBLOCK_TYPES = new Set([ + 'tool-input', + 'skill-input', + 'condition-input', + 'eval-input', + 'webhook-config', + 'schedule-info', + 'trigger-save', + 'input-format', + 'response-format', + 'mcp-server-selector', + 'mcp-tool-selector', + 'mcp-dynamic-args', + 'input-mapping', + 'variables-input', + 'messages-input', + 'router-input', + 'text', +]) + +export interface SubBlocksForToolInput { + toolConfig: ToolConfig + subBlocks: BlockSubBlockConfig[] + oauthConfig?: OAuthConfig +} + +/** + * Returns filtered SubBlockConfig[] for rendering in tool-input context. + * Uses subblock definitions as the primary source of UI metadata, + * getting all features (wandConfig, rich conditions, dependsOn, etc.) for free. + * + * For blocks without paramVisibility annotations, falls back to inferring + * visibility from the tool's param definitions. + */ +export function getSubBlocksForToolInput( + toolId: string, + blockType: string, + currentValues?: Record, + canonicalModeOverrides?: CanonicalModeOverrides +): SubBlocksForToolInput | null { + try { + const toolConfig = getTool(toolId) + if (!toolConfig) { + logger.warn(`Tool not found: ${toolId}`) + return null + } + + const blockConfigs = getBlockConfigurations() + const blockConfig = blockConfigs[blockType] + if (!blockConfig?.subBlocks?.length) { + return null + } + + const allSubBlocks = blockConfig.subBlocks as BlockSubBlockConfig[] + const canonicalIndex = buildCanonicalIndex(allSubBlocks) + + // Build values for condition evaluation + const values = currentValues || {} + const valuesWithOperation = { ...values } + if (valuesWithOperation.operation === undefined) { + const parts = toolId.split('_') + valuesWithOperation.operation = + 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 || {})) + const toolParamVisibility: Record = {} + for (const [paramId, param] of Object.entries(toolConfig.params || {})) { + toolParamVisibility[paramId] = + param.visibility ?? (param.required ? 'user-or-llm' : 'user-only') + } + + // Track which canonical groups we've already included (to avoid duplicates) + const includedCanonicalIds = new Set() + + const filtered: BlockSubBlockConfig[] = [] + + for (const sb of allSubBlocks) { + // Skip excluded types + if (EXCLUDED_SUBBLOCK_TYPES.has(sb.type)) continue + + // Skip trigger-mode-only subblocks + if (sb.mode === 'trigger') continue + + // Determine the effective param ID (canonical or subblock id) + const effectiveParamId = sb.canonicalParamId || sb.id + + // Resolve paramVisibility: explicit > inferred from tool params > skip + let visibility = sb.paramVisibility + if (!visibility) { + // Infer from structural checks + if (STRUCTURAL_SUBBLOCK_IDS.has(sb.id)) { + visibility = 'hidden' + } else if (AUTH_SUBBLOCK_TYPES.has(sb.type)) { + visibility = 'hidden' + } else if ( + sb.password && + (sb.id === 'botToken' || sb.id === 'accessToken' || sb.id === 'apiKey') + ) { + // 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)) { + visibility = 'hidden' + } else { + visibility = toolParamVisibility[sb.id] || 'user-or-llm' + } + } else if (toolParamIds.has(effectiveParamId)) { + // Fallback: infer from tool param visibility + visibility = toolParamVisibility[effectiveParamId] + } else if (toolParamIds.has(sb.id)) { + visibility = toolParamVisibility[sb.id] + } else { + // SubBlock has no corresponding tool param — skip it + continue + } + } + + // Filter by visibility: exclude hidden and llm-only + if (visibility === 'hidden' || visibility === 'llm-only') continue + + // Evaluate condition against current values + if (sb.condition) { + const conditionMet = evaluateSubBlockCondition( + sb.condition as SubBlockCondition, + valuesWithOperation + ) + if (!conditionMet) continue + } + + // Handle canonical pairs: only include the active mode variant + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[sb.id] + if (canonicalId) { + const group = canonicalIndex.groupsById[canonicalId] + if (group && isCanonicalPair(group)) { + if (includedCanonicalIds.has(canonicalId)) continue + includedCanonicalIds.add(canonicalId) + + // Determine active mode + const mode = resolveCanonicalMode(group, valuesWithOperation, canonicalModeOverrides) + if (mode === 'advanced') { + // Find the advanced variant + const advancedSb = allSubBlocks.find((s) => group.advancedIds.includes(s.id)) + if (advancedSb) { + filtered.push(advancedSb) + } + } else { + // Include basic variant (current sb if it's the basic one) + if (group.basicId === sb.id) { + filtered.push(sb) + } else { + const basicSb = allSubBlocks.find((s) => s.id === group.basicId) + if (basicSb) { + filtered.push(basicSb) + } + } + } + continue + } + } + + // Non-canonical, non-hidden, condition-passing subblock + filtered.push(sb) + } + + return { + toolConfig, + subBlocks: filtered, + oauthConfig: toolConfig.oauth, + } + } catch (error) { + logger.error('Error getting subblocks for tool input:', error) + return null + } +}