Skip to content

Commit a0ebe08

Browse files
committed
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
1 parent d236cc8 commit a0ebe08

File tree

10 files changed

+696
-1046
lines changed

10 files changed

+696
-1046
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-sub-block-renderer.tsx

Lines changed: 0 additions & 410 deletions
This file was deleted.

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx renamed to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx

File renamed without changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
'use client'
2+
3+
import type React from 'react'
4+
import { useRef, useState } from 'react'
5+
import { ArrowLeftRight, ArrowUp } from 'lucide-react'
6+
import { Button, Input, Label, Tooltip } from '@/components/emcn'
7+
import { cn } from '@/lib/core/utils/cn'
8+
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
9+
10+
/**
11+
* Props for a generic parameter with label component
12+
*/
13+
export interface ParameterWithLabelProps {
14+
paramId: string
15+
title: string
16+
isRequired: boolean
17+
visibility: string
18+
wandConfig?: {
19+
enabled: boolean
20+
prompt?: string
21+
placeholder?: string
22+
}
23+
canonicalToggle?: {
24+
mode: 'basic' | 'advanced'
25+
disabled?: boolean
26+
onToggle?: () => void
27+
}
28+
disabled: boolean
29+
isPreview: boolean
30+
children: (wandControlRef: React.MutableRefObject<WandControlHandlers | null>) => React.ReactNode
31+
}
32+
33+
/**
34+
* Generic wrapper component for parameters that manages wand state and renders label + input
35+
*/
36+
export function ParameterWithLabel({
37+
paramId,
38+
title,
39+
isRequired,
40+
visibility,
41+
wandConfig,
42+
canonicalToggle,
43+
disabled,
44+
isPreview,
45+
children,
46+
}: ParameterWithLabelProps) {
47+
const [isSearchActive, setIsSearchActive] = useState(false)
48+
const [searchQuery, setSearchQuery] = useState('')
49+
const searchInputRef = useRef<HTMLInputElement>(null)
50+
const wandControlRef = useRef<WandControlHandlers | null>(null)
51+
52+
const isWandEnabled = wandConfig?.enabled ?? false
53+
const showWand = isWandEnabled && !isPreview && !disabled
54+
55+
const handleSearchClick = (): void => {
56+
setIsSearchActive(true)
57+
setTimeout(() => {
58+
searchInputRef.current?.focus()
59+
}, 0)
60+
}
61+
62+
const handleSearchBlur = (): void => {
63+
if (!searchQuery.trim() && !wandControlRef.current?.isWandStreaming) {
64+
setIsSearchActive(false)
65+
}
66+
}
67+
68+
const handleSearchChange = (value: string): void => {
69+
setSearchQuery(value)
70+
}
71+
72+
const handleSearchSubmit = (): void => {
73+
if (searchQuery.trim() && wandControlRef.current) {
74+
wandControlRef.current.onWandTrigger(searchQuery)
75+
setSearchQuery('')
76+
setIsSearchActive(false)
77+
}
78+
}
79+
80+
const handleSearchCancel = (): void => {
81+
setSearchQuery('')
82+
setIsSearchActive(false)
83+
}
84+
85+
const isStreaming = wandControlRef.current?.isWandStreaming ?? false
86+
87+
return (
88+
<div key={paramId} className='relative min-w-0 space-y-[6px]'>
89+
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
90+
<Label className='flex items-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
91+
{title}
92+
{isRequired && visibility === 'user-only' && <span className='ml-0.5'>*</span>}
93+
{visibility !== 'user-only' && (
94+
<span className='ml-[6px] text-[12px] text-[var(--text-tertiary)]'>(optional)</span>
95+
)}
96+
</Label>
97+
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px]'>
98+
{showWand &&
99+
(!isSearchActive ? (
100+
<Button
101+
variant='active'
102+
className='-my-1 h-5 px-2 py-0 text-[11px]'
103+
onClick={handleSearchClick}
104+
>
105+
Generate
106+
</Button>
107+
) : (
108+
<div className='-my-1 flex min-w-[120px] max-w-[280px] flex-1 items-center gap-[4px]'>
109+
<Input
110+
ref={searchInputRef}
111+
value={isStreaming ? 'Generating...' : searchQuery}
112+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
113+
handleSearchChange(e.target.value)
114+
}
115+
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
116+
const relatedTarget = e.relatedTarget as HTMLElement | null
117+
if (relatedTarget?.closest('button')) return
118+
handleSearchBlur()
119+
}}
120+
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
121+
if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) {
122+
handleSearchSubmit()
123+
} else if (e.key === 'Escape') {
124+
handleSearchCancel()
125+
}
126+
}}
127+
disabled={isStreaming}
128+
className={cn(
129+
'h-5 min-w-[80px] flex-1 text-[11px]',
130+
isStreaming && 'text-muted-foreground'
131+
)}
132+
placeholder='Generate with AI...'
133+
/>
134+
<Button
135+
variant='tertiary'
136+
disabled={!searchQuery.trim() || isStreaming}
137+
onMouseDown={(e: React.MouseEvent) => {
138+
e.preventDefault()
139+
e.stopPropagation()
140+
}}
141+
onClick={(e: React.MouseEvent) => {
142+
e.stopPropagation()
143+
handleSearchSubmit()
144+
}}
145+
className='h-[20px] w-[20px] flex-shrink-0 p-0'
146+
>
147+
<ArrowUp className='h-[12px] w-[12px]' />
148+
</Button>
149+
</div>
150+
))}
151+
{canonicalToggle && !isPreview && (
152+
<Tooltip.Root>
153+
<Tooltip.Trigger asChild>
154+
<button
155+
type='button'
156+
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
157+
onClick={canonicalToggle.onToggle}
158+
disabled={canonicalToggle.disabled || disabled}
159+
aria-label={
160+
canonicalToggle.mode === 'advanced'
161+
? 'Switch to selector'
162+
: 'Switch to manual ID'
163+
}
164+
>
165+
<ArrowLeftRight
166+
className={cn(
167+
'!h-[12px] !w-[12px]',
168+
canonicalToggle.mode === 'advanced'
169+
? 'text-[var(--text-primary)]'
170+
: 'text-[var(--text-secondary)]'
171+
)}
172+
/>
173+
</button>
174+
</Tooltip.Trigger>
175+
<Tooltip.Content side='top'>
176+
<p>
177+
{canonicalToggle.mode === 'advanced'
178+
? 'Switch to selector'
179+
: 'Switch to manual ID'}
180+
</p>
181+
</Tooltip.Content>
182+
</Tooltip.Root>
183+
)}
184+
</div>
185+
</div>
186+
<div className='relative w-full min-w-0'>{children(wandControlRef)}</div>
187+
</div>
188+
)
189+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use client'
2+
3+
import { useEffect, useMemo, useRef } from 'react'
4+
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
5+
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
6+
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
7+
8+
interface ToolSubBlockRendererProps {
9+
blockId: string
10+
subBlockId: string
11+
toolIndex: number
12+
subBlock: BlockSubBlockConfig
13+
effectiveParamId: string
14+
toolParams: Record<string, string> | undefined
15+
onParamChange: (toolIndex: number, paramId: string, value: string) => void
16+
disabled: boolean
17+
canonicalToggle?: {
18+
mode: 'basic' | 'advanced'
19+
disabled?: boolean
20+
onToggle?: () => void
21+
}
22+
}
23+
24+
/**
25+
* Bridges the subblock store with StoredTool.params via a synthetic store key,
26+
* then delegates all rendering to SubBlock for full parity.
27+
*
28+
* Two effects handle bidirectional sync:
29+
* - tool.params → store (external changes)
30+
* - store → tool.params (user interaction)
31+
*/
32+
export function ToolSubBlockRenderer({
33+
blockId,
34+
subBlockId,
35+
toolIndex,
36+
subBlock,
37+
effectiveParamId,
38+
toolParams,
39+
onParamChange,
40+
disabled,
41+
canonicalToggle,
42+
}: ToolSubBlockRendererProps) {
43+
const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}`
44+
const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId)
45+
46+
const toolParamValue = toolParams?.[effectiveParamId] ?? ''
47+
48+
/** Tracks the last value we pushed to the store from tool.params to avoid echo loops */
49+
const lastPushedToStoreRef = useRef<string | null>(null)
50+
/** Tracks the last value we synced back to tool.params from the store */
51+
const lastPushedToParamsRef = useRef<string | null>(null)
52+
53+
// Sync tool.params → store: push when the prop value changes (including first mount)
54+
useEffect(() => {
55+
if (!toolParamValue && lastPushedToStoreRef.current === null) {
56+
// Skip initializing the store with an empty value on first mount —
57+
// let the SubBlock component use its own default.
58+
lastPushedToStoreRef.current = toolParamValue
59+
lastPushedToParamsRef.current = toolParamValue
60+
return
61+
}
62+
if (toolParamValue !== lastPushedToStoreRef.current) {
63+
lastPushedToStoreRef.current = toolParamValue
64+
lastPushedToParamsRef.current = toolParamValue
65+
setStoreValue(toolParamValue)
66+
}
67+
}, [toolParamValue, setStoreValue])
68+
69+
// Sync store → tool.params: push when the user changes the value via SubBlock
70+
useEffect(() => {
71+
if (storeValue == null) return
72+
const stringValue = typeof storeValue === 'string' ? storeValue : JSON.stringify(storeValue)
73+
if (stringValue !== lastPushedToParamsRef.current) {
74+
lastPushedToParamsRef.current = stringValue
75+
lastPushedToStoreRef.current = stringValue
76+
onParamChange(toolIndex, effectiveParamId, stringValue)
77+
}
78+
}, [storeValue, toolIndex, effectiveParamId, onParamChange])
79+
80+
// Determine if the parameter is optional for the user (LLM can fill it)
81+
const visibility = subBlock.paramVisibility ?? 'user-or-llm'
82+
const isOptionalForUser = visibility !== 'user-only'
83+
84+
const labelSuffix = useMemo(
85+
() =>
86+
isOptionalForUser ? (
87+
<span className='ml-[6px] text-[12px] text-[var(--text-tertiary)]'>(optional)</span>
88+
) : null,
89+
[isOptionalForUser]
90+
)
91+
92+
// Suppress SubBlock's "*" required indicator for optional-for-user params
93+
const config = {
94+
...subBlock,
95+
id: syntheticId,
96+
...(isOptionalForUser && { required: false }),
97+
}
98+
99+
return (
100+
<SubBlock
101+
blockId={blockId}
102+
config={config}
103+
isPreview={false}
104+
disabled={disabled}
105+
canonicalToggle={canonicalToggle}
106+
labelSuffix={labelSuffix}
107+
/>
108+
)
109+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,12 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
6-
interface StoredTool {
7-
type: string
8-
title?: string
9-
toolId?: string
10-
params?: Record<string, string>
11-
customToolId?: string
12-
schema?: any
13-
code?: string
14-
operation?: string
15-
usageControl?: 'auto' | 'force' | 'none'
16-
}
17-
18-
const isMcpToolAlreadySelected = (selectedTools: StoredTool[], mcpToolId: string): boolean => {
19-
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
20-
}
21-
22-
const isCustomToolAlreadySelected = (
23-
selectedTools: StoredTool[],
24-
customToolId: string
25-
): boolean => {
26-
return selectedTools.some(
27-
(tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId
28-
)
29-
}
30-
31-
const isWorkflowAlreadySelected = (selectedTools: StoredTool[], workflowId: string): boolean => {
32-
return selectedTools.some(
33-
(tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId
34-
)
35-
}
5+
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
6+
import {
7+
isCustomToolAlreadySelected,
8+
isMcpToolAlreadySelected,
9+
isWorkflowAlreadySelected,
10+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils'
3611

3712
describe('isMcpToolAlreadySelected', () => {
3813
describe('basic functionality', () => {

0 commit comments

Comments
 (0)