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
This commit is contained in:
waleed
2026-02-12 00:39:22 -08:00
parent d236cc8ad0
commit a0ebe0842b
10 changed files with 720 additions and 1070 deletions

View File

@@ -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<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

@@ -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<WandControlHandlers | null>) => 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<HTMLInputElement>(null)
const wandControlRef = useRef<WandControlHandlers | null>(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 (
<div key={paramId} className='relative min-w-0 space-y-[6px]'>
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='flex items-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
{title}
{isRequired && visibility === 'user-only' && <span className='ml-0.5'>*</span>}
{visibility !== 'user-only' && (
<span className='ml-[6px] text-[12px] text-[var(--text-tertiary)]'>(optional)</span>
)}
</Label>
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px]'>
{showWand &&
(!isSearchActive ? (
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={handleSearchClick}
>
Generate
</Button>
) : (
<div className='-my-1 flex min-w-[120px] max-w-[280px] flex-1 items-center gap-[4px]'>
<Input
ref={searchInputRef}
value={isStreaming ? 'Generating...' : searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleSearchChange(e.target.value)
}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
const relatedTarget = e.relatedTarget as HTMLElement | null
if (relatedTarget?.closest('button')) return
handleSearchBlur()
}}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
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...'
/>
<Button
variant='tertiary'
disabled={!searchQuery.trim() || isStreaming}
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
handleSearchSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
))}
{canonicalToggle && !isPreview && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
onClick={canonicalToggle.onToggle}
disabled={canonicalToggle.disabled || disabled}
aria-label={
canonicalToggle.mode === 'advanced'
? 'Switch to selector'
: 'Switch to manual ID'
}
>
<ArrowLeftRight
className={cn(
'!h-[12px] !w-[12px]',
canonicalToggle.mode === 'advanced'
? 'text-[var(--text-primary)]'
: 'text-[var(--text-secondary)]'
)}
/>
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{canonicalToggle.mode === 'advanced'
? 'Switch to selector'
: 'Switch to manual ID'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
<div className='relative w-full min-w-0'>{children(wandControlRef)}</div>
</div>
)
}

View File

@@ -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<string, string> | 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<string | null>(null)
/** Tracks the last value we synced back to tool.params from the store */
const lastPushedToParamsRef = useRef<string | null>(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 ? (
<span className='ml-[6px] text-[12px] text-[var(--text-tertiary)]'>(optional)</span>
) : null,
[isOptionalForUser]
)
// Suppress SubBlock's "*" required indicator for optional-for-user params
const config = {
...subBlock,
id: syntheticId,
...(isOptionalForUser && { required: false }),
}
return (
<SubBlock
blockId={blockId}
config={config}
isPreview={false}
disabled={disabled}
canonicalToggle={canonicalToggle}
labelSuffix={labelSuffix}
/>
)
}

View File

@@ -2,37 +2,12 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
interface StoredTool {
type: string
title?: string
toolId?: string
params?: Record<string, string>
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', () => {

View File

@@ -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<string, string>
/** 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<string, 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'
}

View File

@@ -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
)
}

View File

@@ -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 = (
<Label className='flex items-center gap-[6px] whitespace-nowrap'>
{config.title}
{required && <span className='ml-0.5'>*</span>}
{labelSuffix}
{config.type === 'code' &&
config.language === 'json' &&
!isValidJson &&
@@ -385,7 +388,8 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
prevProps.disabled === nextProps.disabled &&
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
canonicalToggleEqual
canonicalToggleEqual &&
prevProps.labelSuffix === nextProps.labelSuffix
)
}
@@ -415,6 +419,7 @@ function SubBlockComponent({
fieldDiffStatus,
allowExpandInPreview,
canonicalToggle,
labelSuffix,
}: SubBlockProps): JSX.Element {
const [isValidJson, setIsValidJson] = useState(true)
const [isSearchActive, setIsSearchActive] = useState(false)
@@ -1059,7 +1064,8 @@ function SubBlockComponent({
showCopyButton: Boolean(config.showCopyButton && config.useWebhookUrl),
copied,
onCopy: handleCopy,
}
},
labelSuffix
)}
{renderInput()}
</div>

View File

@@ -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<string, ParameterVisibility> = {}
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 {