diff --git a/sim/app/w/[id]/components/workflow-loop/components/loop-input/loop-input.tsx b/sim/app/w/[id]/components/workflow-loop/components/loop-input/loop-input.tsx index 0f3eee1926..c3c0af9f6c 100644 --- a/sim/app/w/[id]/components/workflow-loop/components/loop-input/loop-input.tsx +++ b/sim/app/w/[id]/components/workflow-loop/components/loop-input/loop-input.tsx @@ -8,6 +8,7 @@ import { NodeProps } from 'reactflow' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { cn } from '@/lib/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -28,6 +29,11 @@ export function LoopInput({ id }: NodeProps) { const [open, setOpen] = useState(false) const editorRef = useRef(null) + // State for tag dropdown + const [showTags, setShowTags] = useState(false) + const [cursorPosition, setCursorPosition] = useState(0) + const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) + // Initialize editor value from the store useEffect(() => { if (loopType === 'forEach' && loop?.forEachItems) { @@ -79,10 +85,27 @@ export function LoopInput({ id }: NodeProps) { } } + // Handle tag selection + const handleTagSelect = (newValue: string) => { + setEditorValue(newValue) + updateLoopForEachItems(loopId, newValue) + setShowTags(false) + } + const handleEditorChange = (value: string) => { // Always set the editor value to exactly what the user typed setEditorValue(value) + // Get current cursor position (approximation for editor) + const textArea = editorRef.current?.querySelector('textarea') + if (textArea) { + setCursorPosition(textArea.selectionStart || 0) + + // Check for tag trigger + const tagTrigger = checkTagTrigger(value, textArea.selectionStart || 0) + setShowTags(tagTrigger.show) + } + // Save the items to the store for forEach loops if (loopType === 'forEach') { // Pass the exact string to preserve formatting @@ -90,6 +113,52 @@ export function LoopInput({ id }: NodeProps) { } } + // Handle editor focus + const handleEditorFocus = () => { + // Reset tag dropdown state + setShowTags(false) + setActiveSourceBlockId(null) + } + + // Handle editor blur + const handleEditorBlur = () => { + // We don't immediately hide the tag dropdown to allow clicking on it + setTimeout(() => { + if (!document.activeElement?.closest('.tag-dropdown')) { + setShowTags(false) + } + }, 100) + } + + // Add and remove keyboard event listener for editor + useEffect(() => { + const textArea = editorRef.current?.querySelector('textarea') + if (!textArea) return + + const handleKeyboardEvent = (e: KeyboardEvent) => { + // Update cursor position when key is pressed in editor + setCursorPosition(textArea.selectionStart || 0) + + // Check for tag trigger + const tagTrigger = checkTagTrigger(editorValue, textArea.selectionStart || 0) + + if (e.key === 'Escape') { + setShowTags(false) + } else if (e.key === '<') { + // Show tags dropdown when '<' is typed + setShowTags(true) + } else if (e.key === '>') { + // Hide tags dropdown when '>' is typed + setShowTags(false) + } else { + setShowTags(tagTrigger.show) + } + } + + textArea.addEventListener('keyup', handleKeyboardEvent) + return () => textArea.removeEventListener('keyup', handleKeyboardEvent) + }, [editorValue, editorRef]) + // Determine label based on loop type const getLabel = () => { switch (loopType) { @@ -149,27 +218,45 @@ export function LoopInput({ id }: NodeProps) { /> ) : ( - // Code editor for 'forEach' loops -
- {editorValue === '' && ( -
- {getPlaceholder()} -
- )} - highlight(code, languages.javascript, 'javascript')} - padding={0} - style={{ - fontFamily: 'monospace', - lineHeight: '21px', + // Code editor for 'forEach' loops with tag dropdown support +
+
+ {editorValue === '' && ( +
+ {getPlaceholder()} +
+ )} + highlight(code, languages.javascript, 'javascript')} + padding={0} + style={{ + fontFamily: 'monospace', + lineHeight: '21px', + }} + className="focus:outline-none w-full" + textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap" + onFocus={handleEditorFocus} + onBlur={handleEditorBlur} + /> +
+ { + setShowTags(false) + setActiveSourceBlockId(null) }} - className="focus:outline-none w-full" - textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap" + className="w-[calc(100%)] tag-dropdown" + style={{ top: 'calc(100% + 4px)' }} />
)} diff --git a/sim/executor/index.ts b/sim/executor/index.ts index e46253808c..4407357df1 100644 --- a/sim/executor/index.ts +++ b/sim/executor/index.ts @@ -45,7 +45,7 @@ export class Executor { this.validateWorkflow() this.workflowInput = workflowInput || {} - this.loopManager = new LoopManager(workflow.loops || {}) + this.loopManager = new LoopManager(workflow.loops || {}, 5, workflowVariables) this.resolver = new InputResolver(workflow, environmentVariables, workflowVariables, this.loopManager) this.pathTracker = new PathTracker(workflow) diff --git a/sim/executor/loops.ts b/sim/executor/loops.ts index 1c326c201d..ed070ab0f9 100644 --- a/sim/executor/loops.ts +++ b/sim/executor/loops.ts @@ -5,10 +5,15 @@ import { ExecutionContext } from './types' * Manages loop detection, iteration limits, and state resets. */ export class LoopManager { + private workflowVariables: Record; + constructor( private loops: Record, - private defaultIterations: number = 5 - ) {} + private defaultIterations: number = 5, + workflowVariables: Record = {} + ) { + this.workflowVariables = workflowVariables; + } /** * Processes all loops and checks if any need to be iterated. @@ -247,8 +252,161 @@ export class LoopManager { return []; } - // First check if it's valid JSON (array or object) - if (trimmedExpression.startsWith('[') || trimmedExpression.startsWith('{')) { + // Import the resolver to handle variable and block references + let InputResolver; + try { + InputResolver = (await import('./resolver')).InputResolver; + } catch (importError) { + console.error(`Failed to import InputResolver: ${importError}`); + return []; + } + + // Create a temporary block for the resolver to use + const dummyBlock = { + id: `temp-${loopId}`, + position: { x: 0, y: 0 }, + config: { + tool: 'loop', + params: { + items: trimmedExpression + } + }, + inputs: {}, + outputs: {}, + enabled: true + }; + + // Create a resolver instance + const resolver = new InputResolver( + context.workflow || { version: '1.0', blocks: [], connections: [], loops: {} }, + context.environmentVariables, + this.workflowVariables, + this + ); + + // Special handling for tag references (single variable tag enclosed in < >) + if (/^<[^>]+>$/.test(trimmedExpression)) { + // For a single tag reference, try to resolve it directly + const resolved = resolver.resolveBlockReferences( + trimmedExpression, + context, + dummyBlock as any + ); + + // If the resolved value has changed, it was successfully resolved + if (resolved !== trimmedExpression) { + try { + // Try to parse as JSON if it looks like JSON + if ((resolved.startsWith('[') && resolved.endsWith(']')) || + (resolved.startsWith('{') && resolved.endsWith('}'))) { + return JSON.parse(resolved); + } else if (resolved.trim() !== '') { + // If it's not JSON but has content, return as a single-item array + return [resolved]; + } + } catch (jsonError) { + // If JSON parsing fails, just return as a single item + return [resolved]; + } + } + } + + // Check if the string contains variables tags (with < and >) + const containsVariableTags = trimmedExpression.includes('<') && trimmedExpression.includes('>'); + + // Handle arrays with variable references: [, , 3, "text"] + if (containsVariableTags && trimmedExpression.startsWith('[') && trimmedExpression.endsWith(']')) { + try { + // Extract the items but preserve the array structure + const itemsStr = trimmedExpression.substring(1, trimmedExpression.length - 1); + + // Use a simple but effective split that respects JSON structure + const items = this.splitArrayItems(itemsStr); + + // Process each item + const result = []; + for (const item of items) { + const trimmedItem = item.trim(); + + // If it's a variable tag, resolve it + if (trimmedItem.includes('<') && trimmedItem.includes('>')) { + // Resolve any variable/block references in this item + const resolvedItem = resolver.resolveBlockReferences( + trimmedItem, + context, + dummyBlock as any + ); + + // If it's still the same as the original, it wasn't resolved + if (resolvedItem === trimmedItem) { + // Keep as is for now - it may be resolvable during iteration + result.push(trimmedItem); + } else { + // Successfully resolved - parse as JSON if possible + try { + if ((resolvedItem.startsWith('[') && resolvedItem.endsWith(']')) || + (resolvedItem.startsWith('{') && resolvedItem.endsWith('}'))) { + result.push(JSON.parse(resolvedItem)); + } else { + // Not JSON, use the primitive value + // Try to convert to appropriate type + if (resolvedItem === 'true') result.push(true); + else if (resolvedItem === 'false') result.push(false); + else if (resolvedItem === 'null') result.push(null); + else if (!isNaN(Number(resolvedItem))) result.push(Number(resolvedItem)); + else result.push(resolvedItem); + } + } catch (jsonError) { + // Not valid JSON, use as is + result.push(resolvedItem); + } + } + } else { + // For non-variable items, parse as JSON + try { + result.push(JSON.parse(trimmedItem)); + } catch (jsonError) { + // If not valid JSON, add as a string + result.push(trimmedItem); + } + } + } + + return result; + } catch (e) { + console.error(`Error processing array with variables for loop ${loopId}:`, e); + // Fall back to treating the whole thing as a string array + return [trimmedExpression]; + } + } + + // Handle objects with variable references: {"key": , "key2": "value"} + if (containsVariableTags && trimmedExpression.startsWith('{') && trimmedExpression.endsWith('}')) { + try { + // For objects, try a different approach - first resolve all variables in the string + const fullyResolvedStr = resolver.resolveBlockReferences( + trimmedExpression, + context, + dummyBlock as any + ); + + // Try to parse the fully resolved string as JSON + try { + return JSON.parse(fullyResolvedStr); + } catch (jsonError) { + console.error(`Error parsing resolved object for loop ${loopId}:`, jsonError); + // If that fails, return as a string + return [fullyResolvedStr]; + } + } catch (e) { + console.error(`Error processing object with variables for loop ${loopId}:`, e); + return [trimmedExpression]; + } + } + + // If it doesn't contain variable tags, or isn't an array/object with tags, + // continue with standard JSON parsing + if (!containsVariableTags && (trimmedExpression.startsWith('[') || trimmedExpression.startsWith('{'))) { try { // Try to parse as JSON first return JSON.parse(trimmedExpression); @@ -259,16 +417,20 @@ export class LoopManager { } // If not valid JSON or JSON parsing failed, try to evaluate as an expression - const result = new Function('context', `return ${loop.forEachItems}`)(context); - - // If the result is an array or object, return it - if (Array.isArray(result) || (typeof result === 'object' && result !== null)) { - return result; - } - - // If it's a primitive, wrap it in an array - if (result !== undefined) { - return [result]; + try { + const result = new Function('context', `return ${loop.forEachItems}`)(context); + + // If the result is an array or object, return it + if (Array.isArray(result) || (typeof result === 'object' && result !== null)) { + return result; + } + + // If it's a primitive, wrap it in an array + if (result !== undefined) { + return [result]; + } + } catch (evalError) { + console.error(`Error evaluating expression for loop ${loopId}:`, evalError); } return []; @@ -296,6 +458,74 @@ export class LoopManager { return []; } + /** + * Splits array items respecting nested structures like objects and arrays. + * This is a more robust way to split array items than simply splitting by commas. + * + * @param str - The string containing array items (without the outer brackets) + * @returns Array of item strings + */ + private splitArrayItems(str: string): string[] { + const result: string[] = []; + let currentItem = ''; + let depth = 0; + let inString = false; + let escapeNext = false; + + for (let i = 0; i < str.length; i++) { + const char = str[i]; + + // Handle escape sequences in strings + if (inString && char === '\\' && !escapeNext) { + escapeNext = true; + currentItem += char; + continue; + } + + // If we just processed an escape, reset the flag + if (escapeNext) { + escapeNext = false; + currentItem += char; + continue; + } + + // Handle string boundaries + if (char === '"' || char === "'") { + inString = !inString; + currentItem += char; + continue; + } + + // Skip processing special characters if we're in a string + if (inString) { + currentItem += char; + continue; + } + + // Handle nested structures + if (char === '[' || char === '{') { + depth++; + currentItem += char; + } else if (char === ']' || char === '}') { + depth--; + currentItem += char; + } else if (char === ',' && depth === 0) { + // Only split on commas at the top level + result.push(currentItem); + currentItem = ''; + } else { + currentItem += char; + } + } + + // Add the last item if there is one + if (currentItem.trim()) { + result.push(currentItem); + } + + return result; + } + /** * Finds the entry block for a loop (the one that should be executed first). * Typically the block with the fewest incoming connections. diff --git a/sim/executor/resolver.ts b/sim/executor/resolver.ts index 5aad7e4759..73e9cb443e 100644 --- a/sim/executor/resolver.ts +++ b/sim/executor/resolver.ts @@ -195,7 +195,26 @@ export class InputResolver { // Get the current item directly from the items array at the current index if (Array.isArray(items) && index >= 0 && index < items.length) { - const currentItem = items[index]; + let currentItem = items[index]; + + // Special handling for variable references stored as strings in loop items + if (typeof currentItem === 'string' && currentItem.startsWith('<') && currentItem.endsWith('>') && + currentItem.includes('variable.')) { + // This is a variable reference stored as a string in the array + // Try to resolve it using the same variable resolution logic + const resolvedVar = this.resolveVariableReferences(currentItem); + + // If it was resolved (changed), update the current item + if (resolvedVar !== currentItem) { + currentItem = resolvedVar; + + // Try to convert to appropriate primitive type if possible + if (resolvedVar === 'true') currentItem = true; + else if (resolvedVar === 'false') currentItem = false; + else if (resolvedVar === 'null') currentItem = null; + else if (!isNaN(Number(resolvedVar))) currentItem = Number(resolvedVar); + } + } // Format the value based on type if (currentItem !== undefined) { diff --git a/sim/stores/workflows/workflow/store.ts b/sim/stores/workflows/workflow/store.ts index 9c290de31a..e064298ec2 100644 --- a/sim/stores/workflows/workflow/store.ts +++ b/sim/stores/workflows/workflow/store.ts @@ -563,19 +563,22 @@ export const useWorkflowStore = create()( }, updateLoopForEachItems: (loopId: string, items: string) => { - let parsedItems: any = items; + // Don't try to parse JSON if the string contains variable tags <...> + const containsVariableTags = items.includes('<') && items.includes('>') - // Try to parse the string as JSON if it looks like JSON - if (typeof items === 'string' && + let parsedItems = items; + + // Only try to parse as JSON if it doesn't contain variable tags + if (!containsVariableTags && + typeof items === 'string' && ((items.trim().startsWith('[') && items.trim().endsWith(']')) || (items.trim().startsWith('{') && items.trim().endsWith('}'))) ) { try { // First try to parse to validate it's valid JSON - const parsed = JSON.parse(items); + JSON.parse(items); // If parsing succeeds, store the original string to preserve formatting - // This way we keep the user's exact formatting (spacing, line breaks, etc.) parsedItems = items; } catch (e) { // If parsing fails, keep it as a string expression @@ -586,7 +589,7 @@ export const useWorkflowStore = create()( const newState = { blocks: { ...get().blocks }, - edges: [...get().edges], + edges: [...get().edges ], loops: { ...get().loops, [loopId]: {