Compare commits

...

1 Commits

Author SHA1 Message Date
waleed
7210330f75 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 <noreply@anthropic.com>
2026-02-09 09:18:45 -08:00
5 changed files with 977 additions and 992 deletions

View File

@@ -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<string, string> | undefined
onParamChange: (toolIndex: number, paramId: string, value: string) => void
disabled: boolean
previewContextValues?: Record<string, unknown>
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
}
/**
* 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<string>(toolParamValue)
/** Tracks the last value we synced back to tool.params from the store */
const lastSyncRef = useRef<string>(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<string, unknown>
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
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 (
<ShortInput
blockId={blockId}
subBlockId={syntheticId}
placeholder={subBlock.placeholder}
password={subBlock.password || isPasswordParameter(subBlock.id)}
config={config}
disabled={disabled}
wandControlRef={wandControlRef}
hideInternalWand={true}
/>
)
case 'long-input':
return (
<LongInput
blockId={blockId}
subBlockId={syntheticId}
placeholder={subBlock.placeholder}
rows={subBlock.rows}
config={config}
disabled={disabled}
wandControlRef={wandControlRef}
hideInternalWand={true}
/>
)
case 'dropdown':
return (
<Combobox
options={
(subBlock.options as { label: string; id: string }[] | undefined)
?.filter((option) => option.id !== '')
.map((option) => ({
label: option.label,
value: option.id,
})) || []
}
value={toolParamValue}
onChange={onParamChange}
placeholder={subBlock.placeholder || 'Select option'}
disabled={disabled}
/>
)
case 'switch':
return (
<Switch
checked={toolParamValue === 'true' || toolParamValue === 'True'}
onCheckedChange={(checked) => onParamChange(checked ? 'true' : 'false')}
/>
)
case 'code':
return (
<Code
blockId={blockId}
subBlockId={syntheticId}
placeholder={subBlock.placeholder}
language={subBlock.language}
generationType={subBlock.generationType}
value={typeof subBlock.value === 'function' ? subBlock.value({}) : undefined}
disabled={disabled}
wandConfig={
subBlock.wandConfig || {
enabled: false,
prompt: '',
placeholder: '',
}
}
wandControlRef={wandControlRef}
hideInternalWand={true}
/>
)
case 'channel-selector':
case 'user-selector':
return (
<SlackSelectorInput
blockId={blockId}
subBlock={config}
disabled={disabled}
previewContextValues={previewContextValues}
/>
)
case 'project-selector':
return (
<ProjectSelectorInput
blockId={blockId}
subBlock={config}
disabled={disabled}
previewContextValues={previewContextValues}
/>
)
case 'file-selector':
return (
<FileSelectorInput
blockId={blockId}
subBlock={config}
disabled={disabled}
previewContextValues={previewContextValues}
/>
)
case 'sheet-selector':
return (
<SheetSelectorInput
blockId={blockId}
subBlock={config}
disabled={disabled}
previewContextValues={previewContextValues}
/>
)
case 'folder-selector':
return (
<FolderSelectorInput
blockId={blockId}
subBlock={config}
disabled={disabled}
previewContextValues={previewContextValues}
/>
)
case 'knowledge-base-selector':
return <KnowledgeBaseSelector blockId={blockId} subBlock={config} disabled={disabled} />
case 'document-selector':
return (
<DocumentSelector
blockId={blockId}
subBlock={config}
disabled={disabled}
previewContextValues={previewContextValues}
/>
)
case 'document-tag-entry':
return (
<DocumentTagEntry
blockId={blockId}
subBlock={config}
disabled={disabled}
previewContextValues={previewContextValues}
/>
)
case 'knowledge-tag-filters':
return (
<KnowledgeTagFilters
blockId={blockId}
subBlock={config}
disabled={disabled}
previewContextValues={previewContextValues}
/>
)
case 'table':
return (
<Table
blockId={blockId}
subBlockId={syntheticId}
columns={subBlock.columns ?? []}
disabled={disabled}
/>
)
case 'slider':
return (
<SliderInput
blockId={blockId}
subBlockId={syntheticId}
min={subBlock.min}
max={subBlock.max}
step={subBlock.step}
integer={subBlock.integer}
disabled={disabled}
/>
)
case 'checkbox-list':
return (
<CheckboxList
blockId={blockId}
subBlockId={syntheticId}
title={subBlock.title ?? ''}
options={subBlock.options as { label: string; id: string }[]}
disabled={disabled}
/>
)
case 'time-input':
return (
<TimeInput
blockId={blockId}
subBlockId={syntheticId}
placeholder={subBlock.placeholder}
disabled={disabled}
/>
)
case 'file-upload':
return (
<FileUpload
blockId={blockId}
subBlockId={syntheticId}
acceptedTypes={subBlock.acceptedTypes || '*'}
multiple={subBlock.multiple === true}
maxSize={subBlock.maxSize}
disabled={disabled}
/>
)
case 'combobox':
return (
<Combobox
options={((subBlock.options as { label: string; id: string }[] | undefined) || []).map(
(opt) => ({
label: opt.label,
value: opt.id,
})
)}
value={toolParamValue}
onChange={onParamChange}
placeholder={subBlock.placeholder || 'Select option'}
disabled={disabled}
/>
)
case 'workflow-selector':
return <WorkflowSelectorInput blockId={blockId} subBlock={config} disabled={disabled} />
case 'oauth-input':
// OAuth inputs are handled separately by ToolCredentialSelector in the parent
return null
default:
return (
<ShortInput
blockId={blockId}
subBlockId={syntheticId}
placeholder={subBlock.placeholder}
password={isPasswordParameter(subBlock.id)}
config={config}
disabled={disabled}
/>
)
}
}

View File

@@ -196,6 +196,8 @@ export interface SubBlockConfig {
type: SubBlockType type: SubBlockType
mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode
canonicalParamId?: string canonicalParamId?: string
/** Controls parameter visibility in agent/tool-input context */
paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden'
required?: required?:
| boolean | boolean
| { | {

View File

@@ -1,6 +1,7 @@
import { import {
buildCanonicalIndex, buildCanonicalIndex,
type CanonicalIndex, type CanonicalIndex,
type CanonicalModeOverrides,
evaluateSubBlockCondition, evaluateSubBlockCondition,
getCanonicalValues, getCanonicalValues,
isCanonicalPair, isCanonicalPair,
@@ -12,7 +13,10 @@ import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
export { export {
buildCanonicalIndex, buildCanonicalIndex,
type CanonicalIndex, type CanonicalIndex,
type CanonicalModeOverrides,
evaluateSubBlockCondition, evaluateSubBlockCondition,
isCanonicalPair,
resolveCanonicalMode,
type SubBlockCondition, type SubBlockCondition,
} }

View File

@@ -1,13 +1,17 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { import {
buildCanonicalIndex,
type CanonicalModeOverrides,
evaluateSubBlockCondition, evaluateSubBlockCondition,
isCanonicalPair,
resolveCanonicalMode,
type SubBlockCondition, type SubBlockCondition,
} from '@/lib/workflows/subblocks/visibility' } 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 { safeAssign } from '@/tools/safe-assign'
import { isEmptyTagValue } from '@/tools/shared/tags' 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' import { getTool } from '@/tools/utils'
const logger = createLogger('ToolsParams') const logger = createLogger('ToolsParams')
@@ -64,6 +68,14 @@ export interface UIComponentConfig {
mode?: 'basic' | 'advanced' | 'both' | 'trigger' mode?: 'basic' | 'advanced' | 'both' | 'trigger'
/** The actual subblock ID this config was derived from */ /** The actual subblock ID this config was derived from */
actualSubBlockId?: string actualSubBlockId?: string
/** Wand configuration for AI assistance */
wandConfig?: {
enabled: boolean
prompt: string
generationType?: GenerationType
placeholder?: string
maintainHistory?: boolean
}
} }
export interface SubBlockConfig { export interface SubBlockConfig {
@@ -327,6 +339,7 @@ export function getToolParametersConfig(
canonicalParamId: subBlock.canonicalParamId, canonicalParamId: subBlock.canonicalParamId,
mode: subBlock.mode, mode: subBlock.mode,
actualSubBlockId: subBlock.id, actualSubBlockId: subBlock.id,
wandConfig: subBlock.wandConfig,
} }
} }
} }
@@ -811,3 +824,196 @@ export function formatParameterLabel(paramId: string): string {
// Simple case - just capitalize first letter // Simple case - just capitalize first letter
return paramId.charAt(0).toUpperCase() + paramId.slice(1) 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<string, unknown>,
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<string, ParameterVisibility> = {}
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<string>()
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
}
}