diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/combobox.tsx index a72e654fba..7ccf40448b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/combobox.tsx @@ -10,6 +10,7 @@ import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/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 { useTagSelection } from '@/hooks/use-tag-selection' @@ -60,6 +61,7 @@ export function ComboBox({ const [highlightedIndex, setHighlightedIndex] = useState(-1) const emitTagSelection = useTagSelection(blockId, subBlockId) + const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) const inputRef = useRef(null) const overlayRef = useRef(null) @@ -432,7 +434,10 @@ export function ComboBox({ style={{ right: '42px' }} >
- {formatDisplayText(displayValue)} + {formatDisplayText(displayValue, { + accessiblePrefixes, + highlightAll: !accessiblePrefixes, + })}
{/* Chevron button */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/input-mapping/input-mapping.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/input-mapping/input-mapping.tsx index ad4736e9c4..1b48f6f005 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/input-mapping/input-mapping.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/input-mapping/input-mapping.tsx @@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label' import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { cn } from '@/lib/utils' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value' +import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface InputFormatField { @@ -152,6 +153,8 @@ export function InputMapping({ setMapping(updated) } + const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) + if (!selectedWorkflowId) { return (
@@ -213,6 +216,7 @@ export function InputMapping({ blockId={blockId} subBlockId={subBlockId} disabled={isPreview || disabled} + accessiblePrefixes={accessiblePrefixes} /> ) })} @@ -229,6 +233,7 @@ function InputMappingField({ blockId, subBlockId, disabled, + accessiblePrefixes, }: { fieldName: string fieldType?: string @@ -237,6 +242,7 @@ function InputMappingField({ blockId: string subBlockId: string disabled: boolean + accessiblePrefixes: Set | undefined }) { const [showTags, setShowTags] = useState(false) const [cursorPosition, setCursorPosition] = useState(0) @@ -318,7 +324,10 @@ function InputMappingField({ className='w-full whitespace-pre' style={{ scrollbarWidth: 'none', minWidth: 'fit-content' }} > - {formatDisplayText(value)} + {formatDisplayText(value, { + accessiblePrefixes, + highlightAll: !accessiblePrefixes, + })}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx index 63dd78613a..6df8d6582c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx @@ -7,6 +7,7 @@ import { formatDisplayText } from '@/components/ui/formatted-text' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' +import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types' import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions' import { useTagSelection } from '@/hooks/use-tag-selection' @@ -55,6 +56,9 @@ export function KnowledgeTagFilters({ // Use KB tag definitions hook to get available tags const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId) + // Get accessible prefixes for variable highlighting + const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) + // State for managing tag dropdown const [activeTagDropdown, setActiveTagDropdown] = useState<{ rowIndex: number @@ -314,7 +318,12 @@ export function KnowledgeTagFilters({ className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0' />
-
{formatDisplayText(cellValue)}
+
+ {formatDisplayText(cellValue, { + accessiblePrefixes, + highlightAll: !accessiblePrefixes, + })} +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx index 10c2675312..3261c08385 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx @@ -11,6 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value' +import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' import type { SubBlockConfig } from '@/blocks/types' import { useTagSelection } from '@/hooks/use-tag-selection' @@ -92,6 +93,7 @@ export function LongInput({ const overlayRef = useRef(null) const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) const containerRef = useRef(null) + const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) // Use preview value when in preview mode, otherwise use store value or prop value const baseValue = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue @@ -405,7 +407,10 @@ export function LongInput({ height: `${height}px`, }} > - {formatDisplayText(value?.toString() ?? '')} + {formatDisplayText(value?.toString() ?? '', { + accessiblePrefixes, + highlightAll: !accessiblePrefixes, + })} {/* Wand Button */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/short-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/short-input.tsx index 8fbd00d76b..739b04ddfa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/short-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/short-input.tsx @@ -11,6 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value' +import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' import type { SubBlockConfig } from '@/blocks/types' import { useTagSelection } from '@/hooks/use-tag-selection' @@ -345,6 +346,8 @@ export function ShortInput({ } } + const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) + return ( <> {password && !isFocused ? '•'.repeat(value?.toString().length ?? 0) - : formatDisplayText(value?.toString() ?? '')} + : formatDisplayText(value?.toString() ?? '', { + accessiblePrefixes, + highlightAll: !accessiblePrefixes, + })} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx index 44ed4915ee..5741b857ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx @@ -22,6 +22,7 @@ import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { Textarea } from '@/components/ui/textarea' import { cn } from '@/lib/utils' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value' +import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' interface Field { id: string @@ -80,6 +81,7 @@ export function FieldFormat({ const [cursorPosition, setCursorPosition] = useState(0) const [activeFieldId, setActiveFieldId] = useState(null) const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) + const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) // Use preview value when in preview mode, otherwise use store value const value = isPreview ? previewValue : storeValue @@ -471,7 +473,10 @@ export function FieldFormat({ style={{ scrollbarWidth: 'none', minWidth: 'fit-content' }} > {formatDisplayText( - (localValues[field.id] ?? field.value ?? '')?.toString() + (localValues[field.id] ?? field.value ?? '')?.toString(), + accessiblePrefixes + ? { accessiblePrefixes } + : { highlightAll: true } )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx index d31a79b190..aba7bdc397 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx @@ -24,6 +24,7 @@ import { } from '@/components/ui/select' import { createLogger } from '@/lib/logs/console/logger' import type { McpTransport } from '@/lib/mcp/types' +import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useMcpServerTest } from '@/hooks/use-mcp-server-test' import { useMcpServersStore } from '@/stores/mcp-servers/store' @@ -33,6 +34,7 @@ interface McpServerModalProps { open: boolean onOpenChange: (open: boolean) => void onServerCreated?: () => void + blockId: string } interface McpServerFormData { @@ -42,7 +44,12 @@ interface McpServerFormData { headers?: Record } -export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServerModalProps) { +export function McpServerModal({ + open, + onOpenChange, + onServerCreated, + blockId, +}: McpServerModalProps) { const params = useParams() const workspaceId = params.workspaceId as string const [formData, setFormData] = useState({ @@ -262,6 +269,8 @@ export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServe workspaceId, ]) + const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) + return ( @@ -337,7 +346,10 @@ export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServe className='whitespace-nowrap' style={{ transform: `translateX(-${urlScrollLeft}px)` }} > - {formatDisplayText(formData.url || '')} + {formatDisplayText(formData.url || '', { + accessiblePrefixes, + highlightAll: !accessiblePrefixes, + })} @@ -389,7 +401,10 @@ export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServe transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`, }} > - {formatDisplayText(key || '')} + {formatDisplayText(key || '', { + accessiblePrefixes, + highlightAll: !accessiblePrefixes, + })} @@ -417,7 +432,10 @@ export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServe transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`, }} > - {formatDisplayText(value || '')} + {formatDisplayText(value || '', { + accessiblePrefixes, + highlightAll: !accessiblePrefixes, + })} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx index 496d21216b..9eef0a2043 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx @@ -1977,6 +1977,7 @@ export function ToolInput({ // Refresh MCP tools when a new server is created refreshTools(true) }} + blockId={blockId} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts new file mode 100644 index 0000000000..06d6dd795c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts @@ -0,0 +1,64 @@ +import { useMemo } from 'react' +import { shallow } from 'zustand/shallow' +import { BlockPathCalculator } from '@/lib/block-path-calculator' +import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/references' +import { normalizeBlockName } from '@/stores/workflows/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { Loop, Parallel } from '@/stores/workflows/workflow/types' + +export function useAccessibleReferencePrefixes(blockId?: string | null): Set | undefined { + const { blocks, edges, loops, parallels } = useWorkflowStore( + (state) => ({ + blocks: state.blocks, + edges: state.edges, + loops: state.loops || {}, + parallels: state.parallels || {}, + }), + shallow + ) + + return useMemo(() => { + if (!blockId) { + return undefined + } + + const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target })) + const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId) + const accessibleIds = new Set(ancestorIds) + accessibleIds.add(blockId) + + const starterBlock = Object.values(blocks).find((block) => block.type === 'starter') + if (starterBlock) { + accessibleIds.add(starterBlock.id) + } + + const loopValues = Object.values(loops as Record) + loopValues.forEach((loop) => { + if (!loop?.nodes) return + if (loop.nodes.includes(blockId)) { + loop.nodes.forEach((nodeId) => accessibleIds.add(nodeId)) + } + }) + + const parallelValues = Object.values(parallels as Record) + parallelValues.forEach((parallel) => { + if (!parallel?.nodes) return + if (parallel.nodes.includes(blockId)) { + parallel.nodes.forEach((nodeId) => accessibleIds.add(nodeId)) + } + }) + + const prefixes = new Set() + accessibleIds.forEach((id) => { + prefixes.add(normalizeBlockName(id)) + const block = blocks[id] + if (block?.name) { + prefixes.add(normalizeBlockName(block.name)) + } + }) + + SYSTEM_REFERENCE_PREFIXES.forEach((prefix) => prefixes.add(prefix)) + + return prefixes + }, [blockId, blocks, edges, loops, parallels]) +} diff --git a/apps/sim/components/ui/formatted-text.tsx b/apps/sim/components/ui/formatted-text.tsx index b61df9be46..b6ac1458ca 100644 --- a/apps/sim/components/ui/formatted-text.tsx +++ b/apps/sim/components/ui/formatted-text.tsx @@ -1,28 +1,50 @@ 'use client' import type { ReactNode } from 'react' +import { normalizeBlockName } from '@/stores/workflows/utils' + +export interface HighlightContext { + accessiblePrefixes?: Set + highlightAll?: boolean +} + +const SYSTEM_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable']) /** * Formats text by highlighting block references (<...>) and environment variables ({{...}}) * Used in code editor, long inputs, and short inputs for consistent syntax highlighting - * - * @param text The text to format */ -export function formatDisplayText(text: string): ReactNode[] { +export function formatDisplayText(text: string, context?: HighlightContext): ReactNode[] { if (!text) return [] - const parts = text.split(/(<[^>]+>|\{\{[^}]+\}\})/g) + const shouldHighlightPart = (part: string): boolean => { + if (!part.startsWith('<') || !part.endsWith('>')) { + return false + } - return parts.map((part, index) => { - if (part.startsWith('<') && part.endsWith('>')) { - return ( - - {part} - - ) + if (context?.highlightAll) { + return true + } + + const inner = part.slice(1, -1) + const [prefix] = inner.split('.') + const normalizedPrefix = normalizeBlockName(prefix) + + if (SYSTEM_PREFIXES.has(normalizedPrefix)) { + return true + } + + if (context?.accessiblePrefixes?.has(normalizedPrefix)) { + return true } - if (part.match(/^\{\{[^}]+\}\}$/)) { + return false + } + + const parts = text.split(/(<[^>]+>|\{\{[^}]+\}\})/g) + + return parts.map((part, index) => { + if (shouldHighlightPart(part) || part.match(/^\{\{[^}]+\}\}$/)) { return ( {part} diff --git a/apps/sim/components/ui/tag-dropdown.tsx b/apps/sim/components/ui/tag-dropdown.tsx index 767271c588..eae8c70bc0 100644 --- a/apps/sim/components/ui/tag-dropdown.tsx +++ b/apps/sim/components/ui/tag-dropdown.tsx @@ -1,13 +1,12 @@ -import type React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronRight } from 'lucide-react' -import { BlockPathCalculator } from '@/lib/block-path-calculator' +import { shallow } from 'zustand/shallow' import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format' import { cn } from '@/lib/utils' import { getBlockOutputPaths, getBlockOutputType } from '@/lib/workflows/block-outputs' +import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' -import { Serializer } from '@/serializer' import { useVariablesStore } from '@/stores/panel/variables/store' import type { Variable } from '@/stores/panel/variables/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -25,6 +24,15 @@ interface BlockTagGroup { distance: number } +interface NestedBlockTagGroup extends BlockTagGroup { + nestedTags: Array<{ + key: string + display: string + fullTag?: string + children?: Array<{ key: string; display: string; fullTag: string }> + }> +} + interface TagDropdownProps { visible: boolean onSelect: (newValue: string) => void @@ -70,6 +78,18 @@ const normalizeVariableName = (variableName: string): string => { return variableName.replace(/\s+/g, '') } +const ensureRootTag = (tags: string[], rootTag: string): string[] => { + if (!rootTag) { + return tags + } + + if (tags.includes(rootTag)) { + return tags + } + + return [rootTag, ...tags] +} + const getSubBlockValue = (blockId: string, property: string): any => { return useSubBlockStore.getState().getValue(blockId, property) } @@ -300,12 +320,27 @@ export const TagDropdown: React.FC = ({ const [parentHovered, setParentHovered] = useState(null) const [submenuHovered, setSubmenuHovered] = useState(false) - const blocks = useWorkflowStore((state) => state.blocks) - const loops = useWorkflowStore((state) => state.loops) - const parallels = useWorkflowStore((state) => state.parallels) - const edges = useWorkflowStore((state) => state.edges) + const { blocks, edges, loops, parallels } = useWorkflowStore( + (state) => ({ + blocks: state.blocks, + edges: state.edges, + loops: state.loops || {}, + parallels: state.parallels || {}, + }), + shallow + ) + const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + const rawAccessiblePrefixes = useAccessibleReferencePrefixes(blockId) + + const combinedAccessiblePrefixes = useMemo(() => { + if (!rawAccessiblePrefixes) return new Set() + const normalized = new Set(rawAccessiblePrefixes) + normalized.add(normalizeBlockName(blockId)) + return normalized + }, [rawAccessiblePrefixes, blockId]) + // Subscribe to live subblock values for the active workflow to react to input format changes const workflowSubBlockValues = useSubBlockStore((state) => workflowId ? (state.workflowValues[workflowId] ?? {}) : {} @@ -325,7 +360,6 @@ export const TagDropdown: React.FC = ({ ) const getVariablesByWorkflowId = useVariablesStore((state) => state.getVariablesByWorkflowId) - const variables = useVariablesStore((state) => state.variables) const workflowVariables = workflowId ? getVariablesByWorkflowId(workflowId) : [] const searchTerm = useMemo(() => { @@ -336,8 +370,12 @@ export const TagDropdown: React.FC = ({ const { tags, - variableInfoMap = {}, - blockTagGroups = [], + variableInfoMap, + blockTagGroups: computedBlockTagGroups, + }: { + tags: string[] + variableInfoMap: Record + blockTagGroups: BlockTagGroup[] } = useMemo(() => { if (activeSourceBlockId) { const sourceBlock = blocks[activeSourceBlockId] @@ -481,6 +519,12 @@ export const TagDropdown: React.FC = ({ } } + blockTags = ensureRootTag(blockTags, normalizedBlockName) + const shouldShowRootTag = sourceBlock.type === 'generic_webhook' + if (!shouldShowRootTag) { + blockTags = blockTags.filter((tag) => tag !== normalizedBlockName) + } + const blockTagGroups: BlockTagGroup[] = [ { blockName, @@ -507,18 +551,7 @@ export const TagDropdown: React.FC = ({ } } - const serializer = new Serializer() - const serializedWorkflow = serializer.serializeWorkflow(blocks, edges, loops, parallels) - - const accessibleBlockIds = BlockPathCalculator.findAllPathNodes( - serializedWorkflow.connections, - blockId - ) - const starterBlock = Object.values(blocks).find((block) => block.type === 'starter') - if (starterBlock && !accessibleBlockIds.includes(starterBlock.id)) { - accessibleBlockIds.push(starterBlock.id) - } const blockDistances: Record = {} if (starterBlock) { @@ -623,6 +656,10 @@ export const TagDropdown: React.FC = ({ const blockTagGroups: BlockTagGroup[] = [] const allBlockTags: string[] = [] + // Use the combinedAccessiblePrefixes to iterate through accessible blocks + const accessibleBlockIds = combinedAccessiblePrefixes + ? Array.from(combinedAccessiblePrefixes) + : [] for (const accessibleBlockId of accessibleBlockIds) { const accessibleBlock = blocks[accessibleBlockId] if (!accessibleBlock) continue @@ -648,7 +685,8 @@ export const TagDropdown: React.FC = ({ const normalizedBlockName = normalizeBlockName(blockName) const outputPaths = generateOutputPaths(mockConfig.outputs) - const blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) + let blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) + blockTags = ensureRootTag(blockTags, normalizedBlockName) blockTagGroups.push({ blockName, @@ -750,6 +788,12 @@ export const TagDropdown: React.FC = ({ } } + blockTags = ensureRootTag(blockTags, normalizedBlockName) + const shouldShowRootTag = accessibleBlock.type === 'generic_webhook' + if (!shouldShowRootTag) { + blockTags = blockTags.filter((tag) => tag !== normalizedBlockName) + } + blockTagGroups.push({ blockName, blockId: accessibleBlockId, @@ -781,51 +825,54 @@ export const TagDropdown: React.FC = ({ } return { - tags: [...variableTags, ...contextualTags, ...allBlockTags], + tags: [...allBlockTags, ...variableTags, ...contextualTags], variableInfoMap, blockTagGroups: finalBlockTagGroups, } }, [ + activeSourceBlockId, + combinedAccessiblePrefixes, + blockId, blocks, edges, + getMergedSubBlocks, loops, parallels, - blockId, - activeSourceBlockId, workflowVariables, - workflowSubBlockValues, - getMergedSubBlocks, + workflowId, ]) const filteredTags = useMemo(() => { if (!searchTerm) return tags - return tags.filter((tag: string) => tag.toLowerCase().includes(searchTerm)) + return tags.filter((tag) => tag.toLowerCase().includes(searchTerm)) }, [tags, searchTerm]) const { variableTags, filteredBlockTagGroups } = useMemo(() => { const varTags: string[] = [] - filteredTags.forEach((tag) => { + filteredTags.forEach((tag: string) => { if (tag.startsWith(TAG_PREFIXES.VARIABLE)) { varTags.push(tag) } }) - const filteredBlockTagGroups = blockTagGroups - .map((group) => ({ + const filteredBlockTagGroups = computedBlockTagGroups + .map((group: BlockTagGroup) => ({ ...group, - tags: group.tags.filter((tag) => !searchTerm || tag.toLowerCase().includes(searchTerm)), + tags: group.tags.filter( + (tag: string) => !searchTerm || tag.toLowerCase().includes(searchTerm) + ), })) - .filter((group) => group.tags.length > 0) + .filter((group: BlockTagGroup) => group.tags.length > 0) return { variableTags: varTags, filteredBlockTagGroups, } - }, [filteredTags, blockTagGroups, searchTerm]) + }, [filteredTags, computedBlockTagGroups, searchTerm]) - const nestedBlockTagGroups = useMemo(() => { - return filteredBlockTagGroups.map((group) => { + const nestedBlockTagGroups: NestedBlockTagGroup[] = useMemo(() => { + return filteredBlockTagGroups.map((group: BlockTagGroup) => { const nestedTags: Array<{ key: string display: string @@ -839,7 +886,7 @@ export const TagDropdown: React.FC = ({ > = {} const directTags: Array<{ key: string; display: string; fullTag: string }> = [] - group.tags.forEach((tag) => { + group.tags.forEach((tag: string) => { const tagParts = tag.split('.') if (tagParts.length >= 3) { const parent = tagParts[1] @@ -899,8 +946,8 @@ export const TagDropdown: React.FC = ({ visualTags.push(...variableTags) - nestedBlockTagGroups.forEach((group) => { - group.nestedTags.forEach((nestedTag) => { + nestedBlockTagGroups.forEach((group: NestedBlockTagGroup) => { + group.nestedTags.forEach((nestedTag: any) => { if (nestedTag.children && nestedTag.children.length > 0) { const firstChild = nestedTag.children[0] if (firstChild.fullTag) { @@ -952,8 +999,8 @@ export const TagDropdown: React.FC = ({ if (tag.startsWith(TAG_PREFIXES.VARIABLE)) { const variableName = tag.substring(TAG_PREFIXES.VARIABLE.length) - const variableObj = Object.values(variables).find( - (v) => v.name.replace(/\s+/g, '') === variableName + const variableObj = workflowVariables.find( + (v: Variable) => v.name.replace(/\s+/g, '') === variableName ) if (variableObj) { @@ -985,7 +1032,7 @@ export const TagDropdown: React.FC = ({ onSelect(newValue) onClose?.() }, - [inputValue, cursorPosition, variables, onSelect, onClose] + [inputValue, cursorPosition, workflowVariables, onSelect, onClose] ) useEffect(() => setSelectedIndex(0), [searchTerm]) @@ -1030,7 +1077,7 @@ export const TagDropdown: React.FC = ({ if (selectedIndex < 0 || selectedIndex >= orderedTags.length) return null const selectedTag = orderedTags[selectedIndex] for (let gi = 0; gi < nestedBlockTagGroups.length; gi++) { - const group = nestedBlockTagGroups[gi] + const group = nestedBlockTagGroups[gi]! for (let ni = 0; ni < group.nestedTags.length; ni++) { const nestedTag = group.nestedTags[ni] if (nestedTag.children && nestedTag.children.length > 0) { @@ -1051,16 +1098,16 @@ export const TagDropdown: React.FC = ({ return } - const currentGroup = nestedBlockTagGroups.find((group) => { + const currentGroup = nestedBlockTagGroups.find((group: NestedBlockTagGroup) => { return group.nestedTags.some( - (tag, index) => + (tag: any, index: number) => `${group.blockId}-${tag.key}` === currentHovered.tag && index === currentHovered.index ) }) const currentNestedTag = currentGroup?.nestedTags.find( - (tag, index) => + (tag: any, index: number) => `${currentGroup.blockId}-${tag.key}` === currentHovered.tag && index === currentHovered.index ) @@ -1089,8 +1136,8 @@ export const TagDropdown: React.FC = ({ e.preventDefault() e.stopPropagation() if (submenuIndex >= 0 && submenuIndex < children.length) { - const selectedChild = children[submenuIndex] - handleTagSelect(selectedChild.fullTag, currentGroup) + const selectedChild = children[submenuIndex] as any + handleTagSelect(selectedChild.fullTag, currentGroup as BlockTagGroup | undefined) } break case 'Escape': @@ -1324,7 +1371,7 @@ export const TagDropdown: React.FC = ({ {nestedBlockTagGroups.length > 0 && ( <> {variableTags.length > 0 &&
} - {nestedBlockTagGroups.map((group) => { + {nestedBlockTagGroups.map((group: NestedBlockTagGroup) => { const blockConfig = getBlock(group.blockType) let blockColor = blockConfig?.bgColor || BLOCK_COLORS.DEFAULT @@ -1340,7 +1387,7 @@ export const TagDropdown: React.FC = ({ {group.blockName}
- {group.nestedTags.map((nestedTag, index) => { + {group.nestedTags.map((nestedTag: any, index: number) => { const tagIndex = nestedTag.fullTag ? (tagIndexMap.get(nestedTag.fullTag) ?? -1) : -1 @@ -1505,7 +1552,7 @@ export const TagDropdown: React.FC = ({ }} >
- {nestedTag.children!.map((child, childIndex) => { + {nestedTag.children!.map((child: any, childIndex: number) => { const isKeyboardSelected = inSubmenu && submenuIndex === childIndex const isSelected = isKeyboardSelected diff --git a/apps/sim/executor/resolver/resolver.test.ts b/apps/sim/executor/resolver/resolver.test.ts index e03246b7f6..c4831fd679 100644 --- a/apps/sim/executor/resolver/resolver.test.ts +++ b/apps/sim/executor/resolver/resolver.test.ts @@ -1356,7 +1356,7 @@ describe('InputResolver', () => { expect(result.code).toBe('return "Agent response"') }) - it('should reject references to unconnected blocks', () => { + it('should leave references to unconnected blocks as strings', () => { // Create a new block that is added to the workflow but not connected to isolated-block workflowWithConnections.blocks.push({ id: 'test-block', @@ -1402,9 +1402,9 @@ describe('InputResolver', () => { enabled: true, } - expect(() => connectionResolver.resolveInputs(testBlock, contextWithConnections)).toThrow( - /Block "isolated-block" is not connected to this block/ - ) + // Should not throw - inaccessible references remain as strings + const result = connectionResolver.resolveInputs(testBlock, contextWithConnections) + expect(result.code).toBe('return ') // Reference remains as-is }) it('should always allow references to starter block', () => { @@ -1546,7 +1546,7 @@ describe('InputResolver', () => { expect(otherResult).toBe('content: Hello World') }) - it('should provide helpful error messages for unconnected blocks', () => { + it('should not throw for unconnected blocks and leave references as strings', () => { // Create a test block in the workflow first workflowWithConnections.blocks.push({ id: 'test-block-2', @@ -1592,9 +1592,9 @@ describe('InputResolver', () => { enabled: true, } - expect(() => connectionResolver.resolveInputs(testBlock, contextWithConnections)).toThrow( - /Available connected blocks:.*Agent Block.*Start/ - ) + // Should not throw - references to nonexistent blocks remain as strings + const result = connectionResolver.resolveInputs(testBlock, contextWithConnections) + expect(result.code).toBe('return ') // Reference remains as-is }) it('should work with block names and normalized names', () => { @@ -1725,7 +1725,7 @@ describe('InputResolver', () => { extendedResolver.resolveInputs(block1, extendedContext) }).not.toThrow() - // Should fail for indirect connection + // Should not fail for indirect connection - reference remains as string expect(() => { // Add the response block to the workflow so it can be validated properly extendedWorkflow.blocks.push({ @@ -1748,8 +1748,9 @@ describe('InputResolver', () => { outputs: {}, enabled: true, } - extendedResolver.resolveInputs(block2, extendedContext) - }).toThrow(/Block "agent-1" is not connected to this block/) + const result = extendedResolver.resolveInputs(block2, extendedContext) + expect(result.test).toBe('') // Reference remains as-is since agent-1 is not accessible + }).not.toThrow() }) it('should handle blocks in same loop referencing each other', () => { diff --git a/apps/sim/executor/resolver/resolver.ts b/apps/sim/executor/resolver/resolver.ts index addcbaa96c..a1953cc210 100644 --- a/apps/sim/executor/resolver/resolver.ts +++ b/apps/sim/executor/resolver/resolver.ts @@ -1,11 +1,13 @@ import { BlockPathCalculator } from '@/lib/block-path-calculator' import { createLogger } from '@/lib/logs/console/logger' import { VariableManager } from '@/lib/variables/variable-manager' +import { extractReferencePrefixes, SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/references' import { TRIGGER_REFERENCE_ALIAS_MAP } from '@/lib/workflows/triggers' import { getBlock } from '@/blocks/index' import type { LoopManager } from '@/executor/loops/loops' import type { ExecutionContext } from '@/executor/types' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' +import { normalizeBlockName } from '@/stores/workflows/utils' const logger = createLogger('InputResolver') @@ -461,64 +463,40 @@ export class InputResolver { return value } - const blockMatches = value.match(/<([^>]+)>/g) - if (!blockMatches) return value + const blockMatches = extractReferencePrefixes(value) + if (blockMatches.length === 0) return value - // Filter out patterns that are clearly not variable references (e.g., comparison operators) - const validBlockMatches = blockMatches.filter((match) => this.isValidVariableReference(match)) - - // If no valid matches found after filtering, return original value - if (validBlockMatches.length === 0) { - return value - } - - // If we're in an API block body, check each valid match to see if it looks like XML rather than a reference - if ( - currentBlock.metadata?.id === 'api' && - validBlockMatches.some((match) => { - const innerContent = match.slice(1, -1) - // Patterns that suggest this is XML, not a block reference: - return ( - innerContent.includes(':') || // namespaces like soap:Envelope - innerContent.includes('=') || // attributes like xmlns="http://..." - innerContent.includes(' ') || // any space indicates attributes - innerContent.includes('/') || // self-closing tags - !innerContent.includes('.') - ) // block refs always have dots - }) - ) { - return value // Likely XML content, return unchanged - } + const accessiblePrefixes = this.getAccessiblePrefixes(currentBlock) let resolvedValue = value - // Check if we're in a template literal for function blocks - const isInTemplateLiteral = - currentBlock.metadata?.id === 'function' && - value.includes('${') && - value.includes('}') && - value.includes('`') + for (const match of blockMatches) { + const { raw, prefix } = match + if (!accessiblePrefixes.has(prefix)) { + continue + } - for (const match of validBlockMatches) { - // Skip variables - they've already been processed - if (match.startsWith(' { + const prefixes = new Set() + + const accessibleBlocks = this.getAccessibleBlocks(block.id) + accessibleBlocks.forEach((blockId) => { + prefixes.add(normalizeBlockName(blockId)) + const sourceBlock = this.blockById.get(blockId) + if (sourceBlock?.metadata?.name) { + prefixes.add(normalizeBlockName(sourceBlock.metadata.name)) + } + }) + + SYSTEM_REFERENCE_PREFIXES.forEach((prefix) => prefixes.add(prefix)) + + return prefixes + } } diff --git a/apps/sim/lib/workflows/references.ts b/apps/sim/lib/workflows/references.ts new file mode 100644 index 0000000000..d8c8f69698 --- /dev/null +++ b/apps/sim/lib/workflows/references.ts @@ -0,0 +1,73 @@ +import { normalizeBlockName } from '@/stores/workflows/utils' + +export const SYSTEM_REFERENCE_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable']) + +const INVALID_REFERENCE_CHARS = /[+*/=<>!]/ + +export function isLikelyReferenceSegment(segment: string): boolean { + if (!segment.startsWith('<') || !segment.endsWith('>')) { + return false + } + + const inner = segment.slice(1, -1) + + if (inner.startsWith(' ')) { + return false + } + + if (inner.match(/^\s*[<>=!]+\s*$/) || inner.match(/\s[<>=!]+\s/)) { + return false + } + + if (inner.match(/^[<>=!]+\s/)) { + return false + } + + if (inner.includes('.')) { + const dotIndex = inner.indexOf('.') + const beforeDot = inner.substring(0, dotIndex) + const afterDot = inner.substring(dotIndex + 1) + + if (afterDot.includes(' ')) { + return false + } + + if (INVALID_REFERENCE_CHARS.test(beforeDot) || INVALID_REFERENCE_CHARS.test(afterDot)) { + return false + } + } else if (INVALID_REFERENCE_CHARS.test(inner) || inner.match(/^\d/) || inner.match(/\s\d/)) { + return false + } + + return true +} + +export function extractReferencePrefixes(value: string): Array<{ raw: string; prefix: string }> { + if (!value || typeof value !== 'string') { + return [] + } + + const matches = value.match(/<[^>]+>/g) + if (!matches) { + return [] + } + + const references: Array<{ raw: string; prefix: string }> = [] + + for (const match of matches) { + if (!isLikelyReferenceSegment(match)) { + continue + } + + const inner = match.slice(1, -1) + const [rawPrefix] = inner.split('.') + if (!rawPrefix) { + continue + } + + const normalized = normalizeBlockName(rawPrefix) + references.push({ raw: match, prefix: normalized }) + } + + return references +} diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index ba5a65e2a5..e4a019bf9d 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -1,4 +1,5 @@ import type { Edge } from 'reactflow' +import { BlockPathCalculator } from '@/lib/block-path-calculator' import { createLogger } from '@/lib/logs/console/logger' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' @@ -44,22 +45,36 @@ export class Serializer { parallels?: Record, validateRequired = false ): SerializedWorkflow { - // Validate subflow requirements (loops/parallels) before serialization if requested + const safeLoops = loops || {} + const safeParallels = parallels || {} + const accessibleBlocksMap = this.computeAccessibleBlockIds( + blocks, + edges, + safeLoops, + safeParallels + ) + if (validateRequired) { - this.validateSubflowsBeforeExecution(blocks, loops || {}, parallels || {}) + this.validateSubflowsBeforeExecution(blocks, safeLoops, safeParallels) } return { version: '1.0', - blocks: Object.values(blocks).map((block) => this.serializeBlock(block, validateRequired)), + blocks: Object.values(blocks).map((block) => + this.serializeBlock(block, { + validateRequired, + allBlocks: blocks, + accessibleBlocksMap, + }) + ), connections: edges.map((edge) => ({ source: edge.source, target: edge.target, sourceHandle: edge.sourceHandle || undefined, targetHandle: edge.targetHandle || undefined, })), - loops, - parallels, + loops: safeLoops, + parallels: safeParallels, } } @@ -156,7 +171,14 @@ export class Serializer { }) } - private serializeBlock(block: BlockState, validateRequired = false): SerializedBlock { + private serializeBlock( + block: BlockState, + options: { + validateRequired: boolean + allBlocks: Record + accessibleBlocksMap: Map> + } + ): SerializedBlock { // Special handling for subflow blocks (loops, parallels, etc.) if (block.type === 'loop' || block.type === 'parallel') { return { @@ -197,7 +219,7 @@ export class Serializer { } // Validate required fields that only users can provide (before execution starts) - if (validateRequired) { + if (options.validateRequired) { this.validateRequiredFieldsBeforeExecution(block, blockConfig, params) } @@ -541,6 +563,46 @@ export class Serializer { } } + private computeAccessibleBlockIds( + blocks: Record, + edges: Edge[], + loops: Record, + parallels: Record + ): Map> { + const accessibleMap = new Map>() + const simplifiedEdges = edges.map((edge) => ({ source: edge.source, target: edge.target })) + + const starterBlock = Object.values(blocks).find((block) => block.type === 'starter') + + Object.keys(blocks).forEach((blockId) => { + const ancestorIds = BlockPathCalculator.findAllPathNodes(simplifiedEdges, blockId) + const accessibleIds = new Set(ancestorIds) + accessibleIds.add(blockId) + + if (starterBlock) { + accessibleIds.add(starterBlock.id) + } + + Object.values(loops).forEach((loop) => { + if (!loop?.nodes) return + if (loop.nodes.includes(blockId)) { + loop.nodes.forEach((nodeId) => accessibleIds.add(nodeId)) + } + }) + + Object.values(parallels).forEach((parallel) => { + if (!parallel?.nodes) return + if (parallel.nodes.includes(blockId)) { + parallel.nodes.forEach((nodeId) => accessibleIds.add(nodeId)) + } + }) + + accessibleMap.set(blockId, accessibleIds) + }) + + return accessibleMap + } + deserializeWorkflow(workflow: SerializedWorkflow): { blocks: Record edges: Edge[]