From 5b0532d473a068d258c53ade21fa9bbfddc004fa Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 14 Feb 2026 11:19:51 -0800 Subject: [PATCH] refactor(tool-input): replace bidirectional effects with zustand subscription (#3215) * refactor(tool-input): replace bidirectional effects with zustand subscription * added wand for custom cron, fixed slack inconsistency * fix slack --- .../content/docs/en/tools/google_books.mdx | 2 +- apps/docs/content/docs/en/tools/s3.mdx | 1 + apps/docs/content/docs/en/tools/slack.mdx | 2 +- apps/sim/app/api/wand/route.ts | 5 + .../components/tools/sub-block-renderer.tsx | 71 +++---- .../components/tool-input/tool-input.tsx | 200 +++++++++++------- apps/sim/blocks/blocks/schedule.ts | 19 ++ apps/sim/blocks/blocks/slack.ts | 2 +- apps/sim/blocks/types.ts | 1 + apps/sim/tools/params.ts | 11 +- apps/sim/tools/slack/message.ts | 4 +- apps/sim/tools/slack/types.ts | 2 +- 12 files changed, 193 insertions(+), 127 deletions(-) diff --git a/apps/docs/content/docs/en/tools/google_books.mdx b/apps/docs/content/docs/en/tools/google_books.mdx index 9baec6846..2b370d139 100644 --- a/apps/docs/content/docs/en/tools/google_books.mdx +++ b/apps/docs/content/docs/en/tools/google_books.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" ## Usage Instructions diff --git a/apps/docs/content/docs/en/tools/s3.mdx b/apps/docs/content/docs/en/tools/s3.mdx index 95715f0f1..f7780eb58 100644 --- a/apps/docs/content/docs/en/tools/s3.mdx +++ b/apps/docs/content/docs/en/tools/s3.mdx @@ -71,6 +71,7 @@ Retrieve an object from an AWS S3 bucket | --------- | ---- | -------- | ----------- | | `accessKeyId` | string | Yes | Your AWS Access Key ID | | `secretAccessKey` | string | Yes | Your AWS Secret Access Key | +| `region` | string | No | Optional region override when URL does not include region \(e.g., us-east-1, eu-west-1\) | | `s3Uri` | string | Yes | S3 Object URL \(e.g., https://bucket.s3.region.amazonaws.com/path/to/file\) | #### Output diff --git a/apps/docs/content/docs/en/tools/slack.mdx b/apps/docs/content/docs/en/tools/slack.mdx index 35562a17e..0f4285e2a 100644 --- a/apps/docs/content/docs/en/tools/slack.mdx +++ b/apps/docs/content/docs/en/tools/slack.mdx @@ -79,7 +79,7 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format | `channel` | string | No | Slack channel ID \(e.g., C1234567890\) | | `dmUserId` | string | No | Slack user ID for direct messages \(e.g., U1234567890\) | | `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) | -| `thread_ts` | string | No | Thread timestamp to reply to \(creates thread reply\) | +| `threadTs` | string | No | Thread timestamp to reply to \(creates thread reply\) | | `files` | file[] | No | Files to attach to the message | #### Output diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index aba3b14ef..f6089ff1d 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -238,6 +238,11 @@ Use this context to calculate relative dates like "yesterday", "last week", "beg finalSystemPrompt += currentTimeContext } + if (generationType === 'cron-expression') { + finalSystemPrompt += + '\n\nIMPORTANT: Return ONLY the raw cron expression (e.g., "0 9 * * 1-5"). Do NOT wrap it in markdown code blocks, backticks, or quotes. Do NOT include any explanation or text before or after the expression.' + } + if (generationType === 'json-object') { finalSystemPrompt += '\n\nIMPORTANT: Return ONLY the raw JSON object. Do NOT wrap it in markdown code blocks (no ```json or ```). Do NOT include any explanation or text before or after the JSON. The response must start with { and end with }.' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx index 81ca1f03c..c9444f3b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx @@ -1,9 +1,10 @@ 'use client' import { useEffect, 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' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' interface ToolSubBlockRendererProps { blockId: string @@ -44,53 +45,43 @@ export function ToolSubBlockRenderer({ canonicalToggle, }: ToolSubBlockRendererProps) { const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}` - const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId) - const toolParamValue = toolParams?.[effectiveParamId] ?? '' const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type) - const lastPushedToStoreRef = useRef(null) - const lastPushedToParamsRef = useRef(null) + const syncedRef = useRef(null) + const onParamChangeRef = useRef(onParamChange) + onParamChangeRef.current = onParamChange useEffect(() => { - if (!toolParamValue && lastPushedToStoreRef.current === null) { - lastPushedToStoreRef.current = toolParamValue - lastPushedToParamsRef.current = toolParamValue - return - } - if (toolParamValue !== lastPushedToStoreRef.current) { - lastPushedToStoreRef.current = toolParamValue - lastPushedToParamsRef.current = toolParamValue + const unsub = useSubBlockStore.subscribe((state, prevState) => { + const wfId = useWorkflowRegistry.getState().activeWorkflowId + if (!wfId) return + const newVal = state.workflowValues[wfId]?.[blockId]?.[syntheticId] + const oldVal = prevState.workflowValues[wfId]?.[blockId]?.[syntheticId] + if (newVal === oldVal) return + const stringified = + newVal == null ? '' : typeof newVal === 'string' ? newVal : JSON.stringify(newVal) + if (stringified === syncedRef.current) return + syncedRef.current = stringified + onParamChangeRef.current(toolIndex, effectiveParamId, stringified) + }) + return unsub + }, [blockId, syntheticId, toolIndex, effectiveParamId]) - if (isObjectType && typeof toolParamValue === 'string' && toolParamValue) { - try { - const parsed = JSON.parse(toolParamValue) - if (typeof parsed === 'object' && parsed !== null) { - setStoreValue(parsed) - return - } - } catch { - // Not valid JSON — fall through to set as string + useEffect(() => { + if (toolParamValue === syncedRef.current) return + syncedRef.current = toolParamValue + if (isObjectType && toolParamValue) { + try { + const parsed = JSON.parse(toolParamValue) + if (typeof parsed === 'object' && parsed !== null) { + useSubBlockStore.getState().setValue(blockId, syntheticId, parsed) + return } - } - setStoreValue(toolParamValue) + } catch {} } - }, [toolParamValue, setStoreValue, isObjectType]) - - useEffect(() => { - if (storeValue == null && lastPushedToParamsRef.current === null) return - const stringValue = - storeValue == null - ? '' - : 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]) + useSubBlockStore.getState().setValue(blockId, syntheticId, toolParamValue) + }, [toolParamValue, blockId, syntheticId, isObjectType]) const visibility = subBlock.paramVisibility ?? 'user-or-llm' const isOptionalForUser = visibility !== 'user-only' 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 f92b8150a..e8fe08e5b 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 @@ -1741,36 +1741,97 @@ export const ToolInput = memo(function ToolInput({ ) : null })()} - {requiresOAuth && oauthConfig && ( -
-
- Account * -
-
- - handleParamChange(toolIndex, 'credential', value) - } - provider={oauthConfig.provider as OAuthProvider} - requiredScopes={ - toolBlock?.subBlocks?.find((sb) => sb.id === 'credential') - ?.requiredScopes || - getCanonicalScopesForProvider(oauthConfig.provider) - } - serviceId={oauthConfig.provider} - disabled={disabled} - /> -
-
- )} - {(() => { const renderedElements: React.ReactNode[] = [] + const showOAuth = + requiresOAuth && oauthConfig && tool.params?.authMethod !== 'bot_token' + + const renderOAuthAccount = (): React.ReactNode => { + if (!showOAuth || !oauthConfig) return null + const credentialSubBlock = toolBlock?.subBlocks?.find( + (s) => s.type === 'oauth-input' + ) + return ( +
+
+ {credentialSubBlock?.title || 'Account'}{' '} + * +
+
+ + handleParamChange(toolIndex, 'credential', value) + } + provider={oauthConfig.provider as OAuthProvider} + requiredScopes={ + credentialSubBlock?.requiredScopes || + getCanonicalScopesForProvider(oauthConfig.provider) + } + serviceId={oauthConfig.provider} + disabled={disabled} + /> +
+
+ ) + } + + const renderSubBlock = (sb: BlockSubBlockConfig): React.ReactNode => { + const effectiveParamId = sb.id + 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 }, + toolScopedOverrides + ) + : undefined + + const canonicalToggleProp = + hasCanonicalPair && canonicalMode && canonicalId + ? { + mode: canonicalMode, + onToggle: () => { + const nextMode = canonicalMode === 'advanced' ? 'basic' : 'advanced' + collaborativeSetBlockCanonicalMode( + blockId, + `${tool.type}:${canonicalId}`, + nextMode + ) + }, + } + : undefined + + const sbWithTitle = sb.title + ? sb + : { ...sb, title: formatParameterLabel(effectiveParamId) } + + return ( + + ) + } + if (useSubBlocks && displaySubBlocks.length > 0) { + const allBlockSubBlocks = toolBlock?.subBlocks || [] const coveredParamIds = new Set( - displaySubBlocks.flatMap((sb) => { + allBlockSubBlocks.flatMap((sb) => { const ids = [sb.id] if (sb.canonicalParamId) ids.push(sb.canonicalParamId) const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id] @@ -1785,57 +1846,45 @@ export const ToolInput = memo(function ToolInput({ }) ) - displaySubBlocks.forEach((sb) => { - const effectiveParamId = sb.id - 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 }, - toolScopedOverrides - ) - : undefined + type RenderItem = + | { kind: 'subblock'; sb: BlockSubBlockConfig } + | { kind: 'oauth' } - const canonicalToggleProp = - hasCanonicalPair && canonicalMode && canonicalId - ? { - mode: canonicalMode, - onToggle: () => { - const nextMode = - canonicalMode === 'advanced' ? 'basic' : 'advanced' - collaborativeSetBlockCanonicalMode( - blockId, - `${tool.type}:${canonicalId}`, - nextMode - ) - }, - } - : undefined + const renderOrder: RenderItem[] = displaySubBlocks.map((sb) => ({ + kind: 'subblock' as const, + sb, + })) - const sbWithTitle = sb.title - ? sb - : { ...sb, title: formatParameterLabel(effectiveParamId) } - - renderedElements.push( - + if (showOAuth) { + const credentialIdx = allBlockSubBlocks.findIndex( + (sb) => sb.type === 'oauth-input' ) - }) + if (credentialIdx >= 0) { + const sbPositions = new Map(allBlockSubBlocks.map((sb, i) => [sb.id, i])) + const insertAt = renderOrder.findIndex( + (item) => + item.kind === 'subblock' && + (sbPositions.get(item.sb.id) ?? Number.POSITIVE_INFINITY) > + credentialIdx + ) + if (insertAt === -1) { + renderOrder.push({ kind: 'oauth' }) + } else { + renderOrder.splice(insertAt, 0, { kind: 'oauth' }) + } + } else { + renderOrder.unshift({ kind: 'oauth' }) + } + } + + for (const item of renderOrder) { + if (item.kind === 'oauth') { + const el = renderOAuthAccount() + if (el) renderedElements.push(el) + } else { + renderedElements.push(renderSubBlock(item.sb)) + } + } const uncoveredParams = displayParams.filter( (param) => @@ -1873,6 +1922,11 @@ export const ToolInput = memo(function ToolInput({ ) } + { + const el = renderOAuthAccount() + if (el) renderedElements.push(el) + } + const filteredParams = displayParams.filter((param) => evaluateParameterCondition(param, tool) ) diff --git a/apps/sim/blocks/blocks/schedule.ts b/apps/sim/blocks/blocks/schedule.ts index fb757543e..0757eca0e 100644 --- a/apps/sim/blocks/blocks/schedule.ts +++ b/apps/sim/blocks/blocks/schedule.ts @@ -122,6 +122,25 @@ export const ScheduleBlock: BlockConfig = { required: true, mode: 'trigger', condition: { field: 'scheduleType', value: 'custom' }, + wandConfig: { + enabled: true, + prompt: `You are an expert at writing cron expressions. Generate a valid cron expression based on the user's description. + +Cron format: minute hour day-of-month month day-of-week +- minute: 0-59 +- hour: 0-23 +- day-of-month: 1-31 +- month: 1-12 +- day-of-week: 0-7 (0 and 7 are Sunday) + +Special characters: * (any), , (list), - (range), / (step) + +{context} + +Return ONLY the cron expression, nothing else. No explanation, no backticks, no quotes.`, + placeholder: 'Describe your schedule (e.g., "every weekday at 9am")', + generationType: 'cron-expression', + }, }, { diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 38f22ca78..c4337fab4 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -604,7 +604,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, case 'send': { baseParams.text = text if (threadTs) { - baseParams.thread_ts = threadTs + baseParams.threadTs = threadTs } // files is the canonical param from attachmentFiles (basic) or files (advanced) const normalizedFiles = normalizeFileInput(files) diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 8ac262bef..30158d734 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -40,6 +40,7 @@ export type GenerationType = | 'neo4j-parameters' | 'timestamp' | 'timezone' + | 'cron-expression' export type SubBlockType = | 'short-input' // Single line input diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 89a9d0f8d..66bbdc292 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -827,11 +827,10 @@ export function formatParameterLabel(paramId: string): string { } /** - * 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. + * SubBlock IDs that control tool routing, not user-facing parameters. + * Excluded from tool-input rendering unless they have an explicit paramVisibility set. */ -const STRUCTURAL_SUBBLOCK_IDS = new Set(['operation', 'authMethod', 'destinationType']) +const STRUCTURAL_SUBBLOCK_IDS = new Set(['operation']) /** * SubBlock types that represent auth/credential inputs handled separately @@ -955,12 +954,8 @@ export function getSubBlocksForToolInput( } 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 } } diff --git a/apps/sim/tools/slack/message.ts b/apps/sim/tools/slack/message.ts index 1fd60b685..b1f047403 100644 --- a/apps/sim/tools/slack/message.ts +++ b/apps/sim/tools/slack/message.ts @@ -57,7 +57,7 @@ export const slackMessageTool: ToolConfig