Skip to content

Commit 602e371

Browse files
waleedlatif1claude
andauthored
refactor(tool-input): subblock-first rendering, component extraction, bug fixes (#3207)
* 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> * 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 * add sibling values to subblock context since subblock store isn't relevant in tool input, and removed unused param * cleanup * fix(tool-input): render uncovered tool params alongside subblocks The SubBlock-first rendering path was hard-returning after rendering subblocks, so tool params without matching subblocks (like inputMapping for workflow tools) were never rendered. Now renders subblocks first, then any remaining displayParams not covered by subblocks via the legacy ParameterWithLabel fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): auto-refresh workflow inputs after redeploy After redeploying a child workflow via the stale badge, the workflow state cache was not invalidated, so WorkflowInputMapperInput kept showing stale input fields until page refresh. Now invalidates workflowKeys.state on deploy success. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): correct workflow selector visibility and tighten (optional) spacing - Set workflowId param to user-only in workflow_executor tool config so "Select Workflow" no longer shows "(optional)" indicator - Tighten (optional) label spacing with -ml-[3px] to counteract parent Label's gap-[6px], making it feel inline with the label text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): align (optional) text to baseline instead of center Use items-baseline instead of items-center on Label flex containers so the smaller (optional) text aligns with the label text baseline rather than sitting slightly below it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): increase top padding of expanded tool body Bump the expanded tool body container's top padding from 8px to 12px for more breathing room between the header bar and the first parameter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): apply extra top padding only to SubBlock-first path Revert container padding to py-[8px] (MCP tools were correct). Wrap SubBlock-first output in a div with pt-[4px] so only registry tools get extra breathing room from the container top. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): increase gap between SubBlock params for visual clarity SubBlock's internal gap (10px between label and input) matched the between-parameter gap (10px), making them indistinguishable. Increase the between-parameter gap to 14px so consecutive parameters are visually distinct, matching the separation seen in ParameterWithLabel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix spacing and optional tag * update styling + move predeploy checks earlier for first time deploys * update change detection to account for synthetic tool ids * fix remaining blocks who had files visibility set to hidden * cleanup * add catch --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9a06cae commit 602e371

File tree

30 files changed

+1532
-1748
lines changed

30 files changed

+1532
-1748
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { useCallback, useState } from 'react'
22
import { createLogger } from '@sim/logger'
3+
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
34
import { useNotificationStore } from '@/stores/notifications'
45
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
6+
import { mergeSubblockState } from '@/stores/workflows/utils'
7+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
58

69
const logger = createLogger('useDeployment')
710

@@ -35,6 +38,24 @@ export function useDeployment({
3538
return { success: true, shouldOpenModal: true }
3639
}
3740

41+
const { blocks, edges, loops, parallels } = useWorkflowStore.getState()
42+
const liveBlocks = mergeSubblockState(blocks, workflowId)
43+
const checkResult = runPreDeployChecks({
44+
blocks: liveBlocks,
45+
edges,
46+
loops,
47+
parallels,
48+
workflowId,
49+
})
50+
if (!checkResult.passed) {
51+
addNotification({
52+
level: 'error',
53+
message: checkResult.error || 'Pre-deploy validation failed',
54+
workflowId,
55+
})
56+
return { success: false, shouldOpenModal: false }
57+
}
58+
3859
setIsDeploying(true)
3960
try {
4061
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Button, Combobox } from '@/components/emcn/components'
44
import {
55
getCanonicalScopesForProvider,
66
getProviderIdFromServiceId,
7+
getServiceConfigByProviderId,
78
OAUTH_PROVIDERS,
89
type OAuthProvider,
910
type OAuthService,
@@ -26,6 +27,11 @@ const getProviderIcon = (providerName: OAuthProvider) => {
2627
}
2728

2829
const getProviderName = (providerName: OAuthProvider) => {
30+
const serviceConfig = getServiceConfigByProviderId(providerName)
31+
if (serviceConfig) {
32+
return serviceConfig.name
33+
}
34+
2935
const { baseProvider } = parseProvider(providerName)
3036
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
3137

@@ -54,7 +60,7 @@ export function ToolCredentialSelector({
5460
onChange,
5561
provider,
5662
requiredScopes = [],
57-
label = 'Select account',
63+
label,
5864
serviceId,
5965
disabled = false,
6066
}: ToolCredentialSelectorProps) {
@@ -64,6 +70,7 @@ export function ToolCredentialSelector({
6470
const { activeWorkflowId } = useWorkflowRegistry()
6571

6672
const selectedId = value || ''
73+
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
6774

6875
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
6976

@@ -203,7 +210,7 @@ export function ToolCredentialSelector({
203210
selectedValue={selectedId}
204211
onChange={handleComboboxChange}
205212
onOpenChange={handleOpenChange}
206-
placeholder={label}
213+
placeholder={effectiveLabel}
207214
disabled={disabled}
208215
editable={true}
209216
filterOptions={!isForeign}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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-baseline 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+
</Label>
94+
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px]'>
95+
{showWand &&
96+
(!isSearchActive ? (
97+
<Button
98+
variant='active'
99+
className='-my-1 h-5 px-2 py-0 text-[11px]'
100+
onClick={handleSearchClick}
101+
>
102+
Generate
103+
</Button>
104+
) : (
105+
<div className='-my-1 flex min-w-[120px] max-w-[280px] flex-1 items-center gap-[4px]'>
106+
<Input
107+
ref={searchInputRef}
108+
value={isStreaming ? 'Generating...' : searchQuery}
109+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
110+
handleSearchChange(e.target.value)
111+
}
112+
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
113+
const relatedTarget = e.relatedTarget as HTMLElement | null
114+
if (relatedTarget?.closest('button')) return
115+
handleSearchBlur()
116+
}}
117+
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
118+
if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) {
119+
handleSearchSubmit()
120+
} else if (e.key === 'Escape') {
121+
handleSearchCancel()
122+
}
123+
}}
124+
disabled={isStreaming}
125+
className={cn(
126+
'h-5 min-w-[80px] flex-1 text-[11px]',
127+
isStreaming && 'text-muted-foreground'
128+
)}
129+
placeholder='Generate with AI...'
130+
/>
131+
<Button
132+
variant='tertiary'
133+
disabled={!searchQuery.trim() || isStreaming}
134+
onMouseDown={(e: React.MouseEvent) => {
135+
e.preventDefault()
136+
e.stopPropagation()
137+
}}
138+
onClick={(e: React.MouseEvent) => {
139+
e.stopPropagation()
140+
handleSearchSubmit()
141+
}}
142+
className='h-[20px] w-[20px] flex-shrink-0 p-0'
143+
>
144+
<ArrowUp className='h-[12px] w-[12px]' />
145+
</Button>
146+
</div>
147+
))}
148+
{canonicalToggle && !isPreview && (
149+
<Tooltip.Root>
150+
<Tooltip.Trigger asChild>
151+
<button
152+
type='button'
153+
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
154+
onClick={canonicalToggle.onToggle}
155+
disabled={canonicalToggle.disabled || disabled}
156+
aria-label={
157+
canonicalToggle.mode === 'advanced'
158+
? 'Switch to selector'
159+
: 'Switch to manual ID'
160+
}
161+
>
162+
<ArrowLeftRight
163+
className={cn(
164+
'!h-[12px] !w-[12px]',
165+
canonicalToggle.mode === 'advanced'
166+
? 'text-[var(--text-primary)]'
167+
: 'text-[var(--text-secondary)]'
168+
)}
169+
/>
170+
</button>
171+
</Tooltip.Trigger>
172+
<Tooltip.Content side='top'>
173+
<p>
174+
{canonicalToggle.mode === 'advanced'
175+
? 'Switch to selector'
176+
: 'Switch to manual ID'}
177+
</p>
178+
</Tooltip.Content>
179+
</Tooltip.Root>
180+
)}
181+
</div>
182+
</div>
183+
<div className='relative w-full min-w-0'>{children(wandControlRef)}</div>
184+
</div>
185+
)
186+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use client'
2+
3+
import { useEffect, 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+
* SubBlock types whose store values are objects/arrays/non-strings.
26+
* tool.params stores strings (via JSON.stringify), so when syncing
27+
* back to the store we parse them to restore the native shape.
28+
*/
29+
const OBJECT_SUBBLOCK_TYPES = new Set(['file-upload', 'table', 'grouped-checkbox-list'])
30+
31+
/**
32+
* Bridges the subblock store with StoredTool.params via a synthetic store key,
33+
* then delegates all rendering to SubBlock for full parity.
34+
*/
35+
export function ToolSubBlockRenderer({
36+
blockId,
37+
subBlockId,
38+
toolIndex,
39+
subBlock,
40+
effectiveParamId,
41+
toolParams,
42+
onParamChange,
43+
disabled,
44+
canonicalToggle,
45+
}: ToolSubBlockRendererProps) {
46+
const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}`
47+
const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId)
48+
49+
const toolParamValue = toolParams?.[effectiveParamId] ?? ''
50+
const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type)
51+
52+
const lastPushedToStoreRef = useRef<string | null>(null)
53+
const lastPushedToParamsRef = useRef<string | null>(null)
54+
55+
useEffect(() => {
56+
if (!toolParamValue && lastPushedToStoreRef.current === null) {
57+
lastPushedToStoreRef.current = toolParamValue
58+
lastPushedToParamsRef.current = toolParamValue
59+
return
60+
}
61+
if (toolParamValue !== lastPushedToStoreRef.current) {
62+
lastPushedToStoreRef.current = toolParamValue
63+
lastPushedToParamsRef.current = toolParamValue
64+
65+
if (isObjectType && typeof toolParamValue === 'string' && toolParamValue) {
66+
try {
67+
const parsed = JSON.parse(toolParamValue)
68+
if (typeof parsed === 'object' && parsed !== null) {
69+
setStoreValue(parsed)
70+
return
71+
}
72+
} catch {
73+
// Not valid JSON — fall through to set as string
74+
}
75+
}
76+
setStoreValue(toolParamValue)
77+
}
78+
}, [toolParamValue, setStoreValue, isObjectType])
79+
80+
useEffect(() => {
81+
if (storeValue == null) return
82+
const stringValue = typeof storeValue === 'string' ? storeValue : JSON.stringify(storeValue)
83+
if (stringValue !== lastPushedToParamsRef.current) {
84+
lastPushedToParamsRef.current = stringValue
85+
lastPushedToStoreRef.current = stringValue
86+
onParamChange(toolIndex, effectiveParamId, stringValue)
87+
}
88+
}, [storeValue, toolIndex, effectiveParamId, onParamChange])
89+
90+
const visibility = subBlock.paramVisibility ?? 'user-or-llm'
91+
const isOptionalForUser = visibility !== 'user-only'
92+
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+
dependencyContext={toolParams}
107+
/>
108+
)
109+
}

0 commit comments

Comments
 (0)