diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx index a669650216..2a01d630a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx @@ -157,7 +157,7 @@ export function ChatMessage({ message }: ChatMessageProps) { {formattedContent && !formattedContent.startsWith('Uploaded') && (
-
+
@@ -168,7 +168,7 @@ export function ChatMessage({ message }: ChatMessageProps) { return (
-
+
{message.isStreaming && }
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 839a3334ff..c5b8f67e2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useReactFlow } from 'reactflow' import { Combobox, type ComboboxOption } from '@/components/emcn/components' import { cn } from '@/lib/core/utils/cn' @@ -7,6 +7,9 @@ import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workfl import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types' +import { getDependsOnFields } from '@/blocks/utils' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' /** * Constants for ComboBox component behavior @@ -48,6 +51,19 @@ interface ComboBoxProps { placeholder?: string /** Configuration for the sub-block */ config: SubBlockConfig + /** Async function to fetch options dynamically */ + fetchOptions?: ( + blockId: string, + subBlockId: string + ) => Promise> + /** Async function to fetch a single option's label by ID (for hydration) */ + fetchOptionById?: ( + blockId: string, + subBlockId: string, + optionId: string + ) => Promise<{ label: string; id: string } | null> + /** Field dependencies that trigger option refetch when changed */ + dependsOn?: SubBlockConfig['dependsOn'] } export function ComboBox({ @@ -61,23 +77,89 @@ export function ComboBox({ disabled, placeholder = 'Type or select an option...', config, + fetchOptions, + fetchOptionById, + dependsOn, }: ComboBoxProps) { // Hooks and context const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) const reactFlowInstance = useReactFlow() + // Dependency tracking for fetchOptions + const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn]) + const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) + const dependencyValues = useSubBlockStore( + useCallback( + (state) => { + if (dependsOnFields.length === 0 || !activeWorkflowId) return [] + const workflowValues = state.workflowValues[activeWorkflowId] || {} + const blockValues = workflowValues[blockId] || {} + return dependsOnFields.map((depKey) => blockValues[depKey] ?? null) + }, + [dependsOnFields, activeWorkflowId, blockId] + ) + ) + // State management const [storeInitialized, setStoreInitialized] = useState(false) + const [fetchedOptions, setFetchedOptions] = useState>([]) + const [isLoadingOptions, setIsLoadingOptions] = useState(false) + const [fetchError, setFetchError] = useState(null) + const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) + const previousDependencyValuesRef = useRef('') + + /** + * Fetches options from the async fetchOptions function if provided + */ + const fetchOptionsIfNeeded = useCallback(async () => { + if (!fetchOptions || isPreview || disabled) return + + setIsLoadingOptions(true) + setFetchError(null) + try { + const options = await fetchOptions(blockId, subBlockId) + setFetchedOptions(options) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' + setFetchError(errorMessage) + setFetchedOptions([]) + } finally { + setIsLoadingOptions(false) + } + }, [fetchOptions, blockId, subBlockId, isPreview, disabled]) // Determine the active value based on mode (preview vs. controlled vs. store) const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue - // Evaluate options if provided as a function - const evaluatedOptions = useMemo(() => { + // Evaluate static options if provided as a function + const staticOptions = useMemo(() => { return typeof options === 'function' ? options() : options }, [options]) + // Normalize fetched options to match ComboBoxOption format + const normalizedFetchedOptions = useMemo((): ComboBoxOption[] => { + return fetchedOptions.map((opt) => ({ label: opt.label, id: opt.id })) + }, [fetchedOptions]) + + // Merge static and fetched options - fetched options take priority when available + const evaluatedOptions = useMemo((): ComboBoxOption[] => { + let opts: ComboBoxOption[] = + fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions + + // Merge hydrated option if not already present + if (hydratedOption) { + const alreadyPresent = opts.some((o) => + typeof o === 'string' ? o === hydratedOption.id : o.id === hydratedOption.id + ) + if (!alreadyPresent) { + opts = [hydratedOption, ...opts] + } + } + + return opts + }, [fetchOptions, normalizedFetchedOptions, staticOptions, hydratedOption]) + // Convert options to Combobox format const comboboxOptions = useMemo((): ComboboxOption[] => { return evaluatedOptions.map((option) => { @@ -160,6 +242,94 @@ export function ComboBox({ } }, [storeInitialized, value, defaultOptionValue, setStoreValue]) + // Clear fetched options and hydrated option when dependencies change + useEffect(() => { + if (fetchOptions && dependsOnFields.length > 0) { + const currentDependencyValuesStr = JSON.stringify(dependencyValues) + const previousDependencyValuesStr = previousDependencyValuesRef.current + + if ( + previousDependencyValuesStr && + currentDependencyValuesStr !== previousDependencyValuesStr + ) { + setFetchedOptions([]) + setHydratedOption(null) + } + + previousDependencyValuesRef.current = currentDependencyValuesStr + } + }, [dependencyValues, fetchOptions, dependsOnFields.length]) + + // Fetch options when needed (on mount, when enabled, or when dependencies change) + useEffect(() => { + if ( + fetchOptions && + !isPreview && + !disabled && + fetchedOptions.length === 0 && + !isLoadingOptions && + !fetchError + ) { + fetchOptionsIfNeeded() + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above + }, [ + fetchOptions, + isPreview, + disabled, + fetchedOptions.length, + isLoadingOptions, + fetchError, + dependencyValues, + ]) + + // Hydrate the stored value's label by fetching it individually + useEffect(() => { + if (!fetchOptionById || isPreview || disabled) return + + const valueToHydrate = value as string | null | undefined + if (!valueToHydrate) return + + // Skip if value is an expression (not a real ID) + if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return + + // Skip if already hydrated with the same value + if (hydratedOption?.id === valueToHydrate) return + + // Skip if value is already in fetched options or static options + const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate) + const alreadyInStaticOptions = staticOptions.some((opt) => + typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate + ) + if (alreadyInFetchedOptions || alreadyInStaticOptions) return + + // Track if effect is still active (cleanup on unmount or value change) + let isActive = true + + // Fetch the hydrated option + fetchOptionById(blockId, subBlockId, valueToHydrate) + .then((option) => { + if (isActive) setHydratedOption(option) + }) + .catch(() => { + if (isActive) setHydratedOption(null) + }) + + return () => { + isActive = false + } + }, [ + fetchOptionById, + value, + blockId, + subBlockId, + isPreview, + disabled, + fetchedOptions, + staticOptions, + hydratedOption?.id, + ]) + /** * Handles wheel event for ReactFlow zoom control * Intercepts Ctrl/Cmd+Wheel to zoom the canvas @@ -247,11 +417,13 @@ export function ComboBox({ return option.id === newValue }) - if (!matchedOption) { - return - } - - const nextValue = typeof matchedOption === 'string' ? matchedOption : matchedOption.id + // If a matching option is found, store its ID; otherwise store the raw value + // (allows expressions like to be entered directly) + const nextValue = matchedOption + ? typeof matchedOption === 'string' + ? matchedOption + : matchedOption.id + : newValue setStoreValue(nextValue) }} isPreview={isPreview} @@ -293,6 +465,13 @@ export function ComboBox({ onWheel: handleWheel, autoComplete: 'off', }} + isLoading={isLoadingOptions} + error={fetchError} + onOpenChange={(open) => { + if (open) { + void fetchOptionsIfNeeded() + } + }} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx index 5754ee1492..9801a4f57e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx @@ -12,6 +12,7 @@ import { getCodeEditorProps, highlight, languages, + Textarea, Tooltip, } from '@/components/emcn' import { Trash } from '@/components/emcn/icons/trash' @@ -74,6 +75,8 @@ interface ConditionInputProps { previewValue?: string | null /** Whether the component is disabled */ disabled?: boolean + /** Mode: 'condition' for code editor, 'router' for text input */ + mode?: 'condition' | 'router' } /** @@ -101,7 +104,9 @@ export function ConditionInput({ isPreview = false, previewValue, disabled = false, + mode = 'condition', }: ConditionInputProps) { + const isRouterMode = mode === 'router' const params = useParams() const workspaceId = params.workspaceId as string const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) @@ -161,32 +166,50 @@ export function ConditionInput({ const shouldPersistRef = useRef(false) /** - * Creates default if/else conditional blocks with stable IDs. + * Creates default blocks with stable IDs. + * For conditions: if/else blocks. For router: one route block. * - * @returns Array of two default blocks (if and else) + * @returns Array of default blocks */ - const createDefaultBlocks = (): ConditionalBlock[] => [ - { - id: generateStableId(blockId, 'if'), - title: 'if', - value: '', - showTags: false, - showEnvVars: false, - searchTerm: '', - cursorPosition: 0, - activeSourceBlockId: null, - }, - { - id: generateStableId(blockId, 'else'), - title: 'else', - value: '', - showTags: false, - showEnvVars: false, - searchTerm: '', - cursorPosition: 0, - activeSourceBlockId: null, - }, - ] + const createDefaultBlocks = (): ConditionalBlock[] => { + if (isRouterMode) { + return [ + { + id: generateStableId(blockId, 'route1'), + title: 'route1', + value: '', + showTags: false, + showEnvVars: false, + searchTerm: '', + cursorPosition: 0, + activeSourceBlockId: null, + }, + ] + } + + return [ + { + id: generateStableId(blockId, 'if'), + title: 'if', + value: '', + showTags: false, + showEnvVars: false, + searchTerm: '', + cursorPosition: 0, + activeSourceBlockId: null, + }, + { + id: generateStableId(blockId, 'else'), + title: 'else', + value: '', + showTags: false, + showEnvVars: false, + searchTerm: '', + cursorPosition: 0, + activeSourceBlockId: null, + }, + ] + } // Initialize with a loading state instead of default blocks const [conditionalBlocks, setConditionalBlocks] = useState([]) @@ -270,10 +293,13 @@ export function ConditionInput({ const parsedBlocks = safeParseJSON(effectiveValueStr) if (parsedBlocks) { - const blocksWithCorrectTitles = parsedBlocks.map((block, index) => ({ - ...block, - title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if', - })) + // For router mode, keep original titles. For condition mode, assign if/else if/else + const blocksWithCorrectTitles = isRouterMode + ? parsedBlocks + : parsedBlocks.map((block, index) => ({ + ...block, + title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if', + })) setConditionalBlocks(blocksWithCorrectTitles) hasInitializedRef.current = true @@ -573,12 +599,17 @@ export function ConditionInput({ /** * Updates block titles based on their position in the array. - * First block is always 'if', last is 'else', middle ones are 'else if'. + * For conditions: First block is 'if', last is 'else', middle ones are 'else if'. + * For router: Titles are user-editable and not auto-updated. * * @param blocks - Array of conditional blocks * @returns Updated blocks with correct titles */ const updateBlockTitles = (blocks: ConditionalBlock[]): ConditionalBlock[] => { + if (isRouterMode) { + // For router mode, don't change titles - they're user-editable + return blocks + } return blocks.map((block, index) => ({ ...block, title: index === 0 ? 'if' : index === blocks.length - 1 ? 'else' : 'else if', @@ -590,13 +621,15 @@ export function ConditionInput({ if (isPreview || disabled) return const blockIndex = conditionalBlocks.findIndex((block) => block.id === afterId) - if (conditionalBlocks[blockIndex]?.title === 'else') return + if (!isRouterMode && conditionalBlocks[blockIndex]?.title === 'else') return - const newBlockId = generateStableId(blockId, `else-if-${Date.now()}`) + const newBlockId = isRouterMode + ? generateStableId(blockId, `route-${Date.now()}`) + : generateStableId(blockId, `else-if-${Date.now()}`) const newBlock: ConditionalBlock = { id: newBlockId, - title: '', + title: isRouterMode ? `route-${Date.now()}` : '', value: '', showTags: false, showEnvVars: false, @@ -710,13 +743,15 @@ export function ConditionInput({
- {block.title} + {isRouterMode ? `Route ${index + 1}` : block.title}
@@ -724,7 +759,7 @@ export function ConditionInput({ - Delete Condition + + {isRouterMode ? 'Delete Route' : 'Delete Condition'} +
- {block.title !== 'else' && + {/* Router mode: show description textarea with tag/env var support */} + {isRouterMode && ( +
e.preventDefault()} + onDrop={(e) => handleDrop(block.id, e)} + > +