diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index b453c54139..4954a6702c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -8,8 +8,6 @@ import { Layers, Play, RefreshCw, - SkipForward, - StepForward, Store, Trash2, WifiOff, @@ -44,6 +42,7 @@ import { getKeyboardShortcutText, useKeyboardShortcuts, } from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts' +import { useExecutionStore } from '@/stores/execution/store' import { useFolderStore } from '@/stores/folders/store' import { usePanelStore } from '@/stores/panel/store' import { useGeneralStore } from '@/stores/settings/general/store' @@ -111,6 +110,9 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { const [isExpanded, setIsExpanded] = useState(false) const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false) const [isAutoLayouting, setIsAutoLayouting] = useState(false) + // Remove chat modal state + // const [isChatPromptOpen, setIsChatPromptOpen] = useState(false) + // const [chatPrompt, setChatPrompt] = useState('') // Delete workflow state - grouped for better organization const [deleteState, setDeleteState] = useState({ @@ -146,6 +148,13 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { } }, [setActiveTab, isOpen, togglePanel]) + const openDebugPanel = useCallback(() => { + setActiveTab('debug') + if (!isOpen) { + togglePanel() + } + }, [setActiveTab, isOpen, togglePanel]) + // Shared condition for keyboard shortcut and button disabled state const isWorkflowBlocked = isExecuting || hasValidationErrors @@ -819,15 +828,29 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { return // Do nothing if no executable blocks } - // Start debugging + // Determine starter id for focus + const starter = Object.values(blocks).find((b) => b.type === 'starter') as any + const starterId = starter?.id as string | undefined + + // Enable debug UI but do NOT start execution if (!isDebugModeEnabled) { toggleDebugMode() } if (usageExceeded) { openSubscriptionSettings() } else { - openConsolePanel() - handleRunWorkflow(undefined, true) // Start in debug mode + // Activate debug session state so the panel is active + const execStore = useExecutionStore.getState() + execStore.setIsExecuting(false) + execStore.setIsDebugging(true) + // Set the Start block as pending - it will execute on first Step + execStore.setPendingBlocks(starterId ? [starterId] : []) + + // Show Debug tab and mark starter as the current block to execute + openDebugPanel() + if (starterId) { + execStore.setActiveBlocks(new Set([starterId])) + } } } }, [ @@ -838,8 +861,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { blocks, handleCancelDebug, toggleDebugMode, - handleRunWorkflow, - openConsolePanel, + openDebugPanel, ]) /** @@ -859,40 +881,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { return (
- - - - - Step Forward - - - - - - - Resume Until End - - + {/* Keep only cancel (X) here; step/resume moved to panel */}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/debug/debug.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/debug/debug.tsx new file mode 100644 index 0000000000..10a2213ef7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/debug/debug.tsx @@ -0,0 +1,2093 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { + AlertCircle, + Check, + Circle, + CircleDot, + FastForward, + Play, + RotateCcw, + Square, + X, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Textarea } from '@/components/ui/textarea' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { BlockPathCalculator } from '@/lib/block-path-calculator' +import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format' +import { cn } from '@/lib/utils' +import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' +import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' +import { getBlock } from '@/blocks' +import { useExecutionStore } from '@/stores/execution/store' +import { useVariablesStore } from '@/stores/panel/variables/store' +import { useEnvironmentStore } from '@/stores/settings/environment/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { mergeSubblockState } from '@/stores/workflows/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { getTool } from '@/tools/utils' +import { getTrigger, getTriggersByProvider } from '@/triggers' + +export function DebugPanel() { + const { + isDebugging, + pendingBlocks, + debugContext, + executor, + activeBlockIds, + setActiveBlocks, + setPanelFocusedBlockId, + panelFocusedBlockId, + setExecutingBlockIds, + setDebugContext, + setPendingBlocks, + breakpointId, + setBreakpointId, + } = useExecutionStore() + const { activeWorkflowId, workflows } = useWorkflowRegistry() + const { handleStepDebug, handleResumeDebug, handleCancelDebug, handleRunWorkflow } = + useWorkflowExecution() + const currentWorkflow = useCurrentWorkflow() + + const [chatMessage, setChatMessage] = useState('') + const [scopedVariables, setScopedVariables] = useState(true) + const [expandedFields, setExpandedFields] = useState>(new Set()) + const [revealedEnvVars, setRevealedEnvVars] = useState>(new Set()) + const hasStartedRef = useRef(false) + const lastFocusedIdRef = useRef(null) + + // Track bottom variables tab and row highlighting for navigation from tokens + const [bottomTab, setBottomTab] = useState<'reference' | 'workflow' | 'environment'>('reference') + const workflowVarRowRefs = useRef>(new Map()) + const envVarRowRefs = useRef>(new Map()) + const [highlightedWorkflowVar, setHighlightedWorkflowVar] = useState(null) + const [highlightedEnvVar, setHighlightedEnvVar] = useState(null) + const refVarRowRefs = useRef>(new Map()) + const [highlightedRefVar, setHighlightedRefVar] = useState(null) + + // Helper to format strings with clickable var/env tokens + const renderWithTokens = (text: string, options?: { truncateAt?: number }) => { + const truncateAt = options?.truncateAt + let displayText = text + let truncated = false + if (typeof truncateAt === 'number' && text.length > truncateAt) { + displayText = `${text.slice(0, truncateAt)}...` + truncated = true + } + + // Build combined matches for env ({{VAR}}), workflow vars (), and references () + const matches: Array<{ + start: number + end: number + type: 'env' | 'var' | 'ref' + value: string + raw?: string + }> = [] + + const envRe = /\{\{([^{}]+)\}\}/g + const varRe = /]+)>/g + const refRe = /<([^>]+)>/g + + let m: RegExpExecArray | null + while ((m = envRe.exec(displayText)) !== null) { + matches.push({ + start: m.index, + end: m.index + m[0].length, + type: 'env', + value: m[1], + raw: m[0], + }) + } + while ((m = varRe.exec(displayText)) !== null) { + matches.push({ + start: m.index, + end: m.index + m[0].length, + type: 'var', + value: m[1], + raw: m[0], + }) + } + while ((m = refRe.exec(displayText)) !== null) { + const inner = m[1] + // Skip workflow variable tokens since already captured + if (inner.startsWith('variable.')) continue + matches.push({ + start: m.index, + end: m.index + m[0].length, + type: 'ref', + value: inner, + raw: m[0], + }) + } + + if (matches.length === 0) { + return ( + {displayText} + ) + } + + // Sort by start index + matches.sort((a, b) => a.start - b.start) + + const parts: React.ReactNode[] = [] + let cursor = 0 + + const handleTokenClick = (kind: 'env' | 'var' | 'ref', rawName: string, rawToken?: string) => { + if (kind === 'env') { + setBottomTab('environment') + // Scroll and highlight + requestAnimationFrame(() => { + const row = envVarRowRefs.current.get(rawName) + if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' }) + setHighlightedEnvVar(rawName) + setTimeout(() => setHighlightedEnvVar((prev) => (prev === rawName ? null : prev)), 2500) + }) + } else { + if (kind === 'var') { + const normalized = (rawName || '').replace(/\s+/g, '') + setBottomTab('workflow') + requestAnimationFrame(() => { + const row = workflowVarRowRefs.current.get(normalized) + if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' }) + setHighlightedWorkflowVar(normalized) + setTimeout( + () => setHighlightedWorkflowVar((prev) => (prev === normalized ? null : prev)), + 2500 + ) + }) + } else { + // Reference variable token + const refKey = rawToken || `<${rawName}>` + setBottomTab('reference') + // Keep current scoped state to match the same set user is seeing + requestAnimationFrame(() => { + const row = refVarRowRefs.current.get(refKey) + if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' }) + setHighlightedRefVar(refKey) + setTimeout(() => setHighlightedRefVar((prev) => (prev === refKey ? null : prev)), 4000) + }) + } + } + } + + for (const match of matches) { + if (match.start > cursor) { + parts.push( + + {displayText.slice(cursor, match.start)} + + ) + } + + const chip = ( + + ) + + parts.push(chip) + cursor = match.end + } + + if (cursor < displayText.length) { + parts.push( + + {displayText.slice(cursor)} + + ) + } + + return {parts} + } + + // Helper to toggle field expansion + const toggleFieldExpansion = (fieldKey: string) => { + setExpandedFields((prev) => { + const next = new Set(prev) + if (next.has(fieldKey)) { + next.delete(fieldKey) + } else { + next.add(fieldKey) + } + return next + }) + } + + // Helper to toggle env var reveal + const toggleEnvVarReveal = (key: string) => { + setRevealedEnvVars((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + } + + // Helper to consistently resolve a human-readable block name + const getDisplayName = (block: any | null | undefined): string => { + if (!block) return '' + return block.name || block.metadata?.name || block.type || block.id || '' + } + + // Always use current workflow blocks as the source of truth + // This ensures consistency whether debugContext exists or not + const blocksList = useMemo(() => { + const blocks = Object.values(currentWorkflow.blocks || {}) as any[] + return blocks.filter((b) => b?.type) // Filter out invalid blocks + }, [currentWorkflow.blocks]) + + const blockById = useMemo(() => { + const map = new Map() + for (const b of blocksList) map.set(b.id, b) + return map + }, [blocksList]) + + // Helpers for infra/virtual handling (parallel & loop) + const isInfraBlockType = (t?: string) => t === 'loop' || t === 'parallel' + const resolveOriginalBlockId = (id: string | null): string | null => { + if (!id) return null + try { + const mapping = debugContext?.parallelBlockMapping?.get(id) + return mapping?.originalBlockId || id + } catch { + return id + } + } + const isVirtualForBlock = (id: string, baseId: string) => { + // Matches executor virtual id scheme: `${baseId}_parallel_${parallelId}_iteration_${i}` + return id.startsWith(`${baseId}_parallel_`) + } + + const starter = useMemo( + () => blocksList.find((b: any) => b.metadata?.id === 'starter' || b.type === 'starter'), + [blocksList] + ) + const starterId = starter?.id || null + + // determine if starter is chat mode in editor state (registry/workflow store keeps subblock values) + const isChatMode = useMemo(() => { + if (!activeWorkflowId) return false + const wf = workflows[activeWorkflowId] + try { + const stateBlocks = (wf as any)?.state?.blocks || {} + const startBlock = Object.values(stateBlocks).find((b: any) => b.type === 'starter') as any + const value = startBlock?.subBlocks?.startWorkflow?.value + return value === 'chat' + } catch { + return false + } + }, [activeWorkflowId, workflows]) + + // Determine focused block: prefer explicitly panel-focused (clicked) block, + // else show first pending; when list empties, keep showing the last focused; initial fallback to starter + const focusedBlockId = useMemo(() => { + const pickResolvedNonInfra = (ids: string[]): string | null => { + for (const rawId of ids) { + const realId = resolveOriginalBlockId(rawId) + const blk = realId ? blockById.get(realId) : null + if (blk && !isInfraBlockType(blk.type)) return realId + } + return null + } + + // 1) Prefer explicit focus if it's not infra (resolve virtuals) + if (panelFocusedBlockId) { + const real = resolveOriginalBlockId(panelFocusedBlockId) + const blk = real ? blockById.get(real) : null + if (blk && !isInfraBlockType(blk.type)) return real + } + // 2) Next, choose first pending that resolves to non-infra + if (pendingBlocks.length > 0) { + const chosen = pickResolvedNonInfra(pendingBlocks) + if (chosen) return chosen + } + // 3) Otherwise keep last focused if still valid + if (lastFocusedIdRef.current) { + const real = resolveOriginalBlockId(lastFocusedIdRef.current) + const blk = real ? blockById.get(real) : null + if (blk && !isInfraBlockType(blk.type)) return real + } + // 4) Fallback to starter + if (starterId) return starterId + return null + }, [panelFocusedBlockId, pendingBlocks, starterId, blockById, debugContext?.parallelBlockMapping]) + + // Remember last focused and publish highlight when pending list changes + useEffect(() => { + if (pendingBlocks.length > 0) { + // Set focus to first non-infra resolved pending if available + const nextReal = (() => { + for (const rawId of pendingBlocks) { + const real = resolveOriginalBlockId(rawId) + const blk = real ? blockById.get(real) : null + if (blk && !isInfraBlockType(blk.type)) return real + } + return pendingBlocks[0] || null + })() + if (nextReal) { + lastFocusedIdRef.current = nextReal + setPanelFocusedBlockId(nextReal) + } + } + }, [pendingBlocks, setPanelFocusedBlockId, blockById]) + + // Get the focused block from our consistent data source + const focusedBlock = focusedBlockId ? blockById.get(focusedBlockId) : null + + // Compute visible subblock values for the focused block based on block state (conditions), + // not UI modes + const visibleSubblockValues = useMemo(() => { + if (!focusedBlockId || !focusedBlock) return {} + + const cfg = getBlock(focusedBlock.type) + const subBlocks = cfg?.subBlocks || [] + + // Get merged state for conditional evaluation + const allBlocks = useWorkflowStore.getState().blocks + const merged = mergeSubblockState(allBlocks, activeWorkflowId || undefined, focusedBlockId)[ + focusedBlockId + ] + const stateToUse: Record = merged?.subBlocks || {} + + const outputs: Record = {} + + for (const sb of subBlocks) { + // Hidden handling + if ((sb as any).hidden) continue + + // Only show trigger-config automatically for pure trigger blocks + if ((sb as any).type === 'trigger-config') { + const isPureTriggerBlock = (cfg as any)?.triggers?.enabled && cfg?.category === 'triggers' + if (!isPureTriggerBlock) continue + } + + // Condition evaluation based on current block state + const cond = + typeof (sb as any).condition === 'function' + ? (sb as any).condition() + : (sb as any).condition + if (cond) { + const fieldValue = stateToUse[cond.field]?.value + const andFieldValue = cond.and ? stateToUse[cond.and.field]?.value : undefined + + const isValueMatch = Array.isArray(cond.value) + ? fieldValue != null && + (cond.not + ? !(cond.value as any[]).includes(fieldValue) + : (cond.value as any[]).includes(fieldValue)) + : cond.not + ? fieldValue !== cond.value + : fieldValue === cond.value + + const isAndValueMatch = !cond.and + ? true + : Array.isArray(cond.and.value) + ? andFieldValue != null && + (cond.and.not + ? !(cond.and.value as any[]).includes(andFieldValue) + : (cond.and.value as any[]).includes(andFieldValue)) + : cond.and.not + ? andFieldValue !== cond.and.value + : andFieldValue === cond.and.value + + if (!(isValueMatch && isAndValueMatch)) continue + } + + // Include only visible subblock values (use the .value for display) + if (stateToUse[sb.id] && 'value' in stateToUse[sb.id]) { + outputs[sb.id] = stateToUse[sb.id].value + } + } + + return outputs + }, [focusedBlockId, focusedBlock, activeWorkflowId]) + + // Latest log for selected block (for input) - requires debugContext + const focusedLog = useMemo(() => { + if (!debugContext?.blockLogs || !focusedBlockId) return null + const logs = debugContext.blockLogs.filter((l) => l.blockId === focusedBlockId) + if (logs.length === 0) return null + return logs.reduce((a, b) => (new Date(a.startedAt) > new Date(b.startedAt) ? a : b)) + }, [debugContext?.blockLogs, focusedBlockId]) + + // Upstream executed outputs to approximate available inputs for non-executed blocks + const upstreamExecuted = useMemo(() => { + if (!focusedBlockId || !debugContext) + return [] as Array<{ id: string; name: string; output: any }> + + // Use currentWorkflow.edges for connections (consistent with block source) + const connections = currentWorkflow.edges || [] + const incoming = connections.filter((c: any) => c.target === focusedBlockId) + const upstreamIds = new Set(incoming.map((c: any) => c.source)) + const items: Array<{ id: string; name: string; output: any }> = [] + upstreamIds.forEach((id) => { + const state = debugContext.blockStates.get(id) + if (state?.executed) { + const blk = blockById.get(id) + items.push({ id, name: blk?.metadata?.name || blk?.id || id, output: state.output }) + } + }) + return items + }, [focusedBlockId, debugContext, blockById, currentWorkflow.edges]) + + const envVars = debugContext?.environmentVariables || {} + const workflowVars = debugContext?.workflowVariables || {} + + // Get environment variables from the store (for before execution starts) + const envVarsFromStore = useEnvironmentStore((state) => state.getAllVariables()) + const loadEnvironmentVariables = useEnvironmentStore((state) => state.loadEnvironmentVariables) + + // Load environment variables when component mounts + useEffect(() => { + loadEnvironmentVariables() + }, [loadEnvironmentVariables]) + + // Use debugContext env vars if available (during execution), otherwise use store + const allEnvVars = useMemo(() => { + // If we have debugContext with env vars, use those (they're decrypted) + if (debugContext && Object.keys(envVars).length > 0) { + return envVars + } + + // Otherwise, use the env vars from the store + // Convert from store format to simple key-value pairs + const storeVars: Record = {} + Object.entries(envVarsFromStore).forEach(([key, variable]) => { + storeVars[key] = variable.value + }) + return storeVars + }, [debugContext, envVars, envVarsFromStore]) + + // Get workflow variables from the variables store + const workflowVariablesFromStore = useVariablesStore((state) => + activeWorkflowId ? state.getVariablesByWorkflowId(activeWorkflowId) : [] + ) + + const isFocusedExecuted = debugContext + ? (() => { + const id = focusedBlockId || '' + if (!id) return false + const direct = debugContext.blockStates.get(id)?.executed + if (direct) return true + // Consider parallel virtual executions for this block + for (const key of debugContext.blockStates.keys()) { + if (isVirtualForBlock(String(key), id) && debugContext.blockStates.get(key)?.executed) { + return true + } + } + return false + })() + : false + + const isFocusedErrored = debugContext + ? (() => { + const id = focusedBlockId || '' + if (!id) return false + // Check direct block state for error + const directState = debugContext.blockStates.get(id) + if ( + directState?.output && + typeof directState.output === 'object' && + 'error' in directState.output + ) { + return true + } + // Check virtual executions for errors + for (const [key, state] of debugContext.blockStates.entries()) { + if ( + isVirtualForBlock(String(key), id) && + state?.output && + typeof state.output === 'object' && + 'error' in state.output + ) { + return true + } + } + // Also check block logs for this block + const hasErrorLog = debugContext.blockLogs?.some( + (log) => + (log.blockId === id || resolveOriginalBlockId(log.blockId) === id) && !log.success + ) + return hasErrorLog || false + })() + : false + + const isStarterFocused = + focusedBlock?.metadata?.id === 'starter' || focusedBlock?.type === 'starter' + const isFocusedCurrent = useMemo(() => { + const id = focusedBlockId || '' + if (!id) return false + return pendingBlocks.some((rawId) => { + if (rawId === id) return true + const real = resolveOriginalBlockId(rawId) + return real === id + }) + }, [pendingBlocks, focusedBlockId, debugContext?.parallelBlockMapping]) + + // Bump when execution progresses to refresh dependent memos + const executionVersion = useMemo(() => { + const logsCount = debugContext?.blockLogs?.length || 0 + const statesCount = debugContext?.blockStates?.size || 0 + return logsCount + statesCount + }, [debugContext?.blockLogs?.length, debugContext?.blockStates?.size]) + + // Resolved output key-value pairs: keys from schema (or actual output if schema empty), + // values from debugContext if available, else null + const resolvedOutputKVs = useMemo(() => { + if (!focusedBlock) return {} + const cfg = getBlock(focusedBlock.type) + const schema = cfg?.outputs || {} + const stateOutput = debugContext?.blockStates.get(focusedBlockId || '')?.output || {} + + const keys = Object.keys(schema).length > 0 ? Object.keys(schema) : Object.keys(stateOutput) + const result: Record = {} + keys.forEach((k) => { + result[k] = Object.hasOwn(stateOutput, k) ? stateOutput[k] : null + }) + return result + }, [focusedBlock, focusedBlockId, executionVersion]) + + // Compute accessible output variables for the focused block with tag-style references + const outputVariableEntries = useMemo(() => { + if (!focusedBlockId) return [] as Array<{ ref: string; value: any }> + + const normalizeBlockName = (name: string) => (name || '').replace(/\s+/g, '').toLowerCase() + const getSubBlockValue = (blockId: string, property: string): any => { + return useSubBlockStore.getState().getValue(blockId, property) + } + const generateOutputPaths = (outputs: Record, prefix = ''): string[] => { + const paths: string[] = [] + for (const [key, value] of Object.entries(outputs || {})) { + const current = prefix ? `${prefix}.${key}` : key + if (typeof value === 'string') { + paths.push(current) + } else if (value && typeof value === 'object') { + if ('type' in value && typeof (value as any).type === 'string') { + paths.push(current) + if ((value as any).type === 'object' && (value as any).properties) { + paths.push(...generateOutputPaths((value as any).properties, current)) + } else if ((value as any).type === 'array' && (value as any).items?.properties) { + paths.push(...generateOutputPaths((value as any).items.properties, current)) + } + } else { + paths.push(...generateOutputPaths(value as Record, current)) + } + } else { + paths.push(current) + } + } + return paths + } + + const getAccessiblePathsForBlock = (blockId: string): string[] => { + const blk = blockById.get(blockId) + if (!blk) return [] + const cfg = getBlock(blk.type) + if (!cfg) return [] + + // Response format overrides + const responseFormatValue = getSubBlockValue(blockId, 'responseFormat') + const responseFormat = parseResponseFormatSafely(responseFormatValue, blockId) + if (responseFormat) { + const fields = extractFieldsFromSchema(responseFormat) + if (fields.length > 0) return fields.map((f: any) => f.name) + } + + if (blk.type === 'evaluator') { + const metricsValue = getSubBlockValue(blockId, 'metrics') + if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) { + const valid = metricsValue.filter((m: { name?: string }) => m?.name) + return valid.map((m: { name: string }) => m.name.toLowerCase()) + } + return generateOutputPaths(cfg.outputs || {}) + } + + if (blk.type === 'starter') { + const startWorkflowValue = getSubBlockValue(blockId, 'startWorkflow') + if (startWorkflowValue === 'chat') { + return ['input', 'conversationId', 'files'] + } + const inputFormatValue = getSubBlockValue(blockId, 'inputFormat') + if (inputFormatValue && Array.isArray(inputFormatValue)) { + return inputFormatValue + .filter((f: { name?: string }) => f.name && f.name.trim() !== '') + .map((f: { name: string }) => f.name) + } + return [] + } + + if (blk.triggerMode && cfg.triggers?.enabled) { + const triggerId = cfg?.triggers?.available?.[0] + const firstTrigger = triggerId ? getTrigger(triggerId) : getTriggersByProvider(blk.type)[0] + if (firstTrigger?.outputs) { + return generateOutputPaths(firstTrigger.outputs) + } + } + + const operationValue = getSubBlockValue(blockId, 'operation') + if (operationValue && cfg?.tools?.config?.tool) { + try { + const toolId = cfg.tools.config.tool({ operation: operationValue }) + const toolConfig = toolId ? getTool(toolId) : null + if (toolConfig?.outputs) return generateOutputPaths(toolConfig.outputs) + } catch {} + } + + return generateOutputPaths(cfg.outputs || {}) + } + + const edges = currentWorkflow.edges || [] + const accessibleIds = new Set( + BlockPathCalculator.findAllPathNodes(edges, focusedBlockId) + ) + + // Always allow referencing the starter block + if (starterId && starterId !== focusedBlockId) accessibleIds.add(starterId) + + const entries: Array<{ ref: string; value: any }> = [] + + // Helper: collect executed outputs including virtual parallel iterations and loop/parallel context items + const collectExecutedOutputs = (baseId: string): Record[] => { + const collected: Record[] = [] + const bs = debugContext?.blockStates + if (bs) { + const direct = bs.get(baseId)?.output + if (direct && typeof direct === 'object') collected.push(direct) + // Include virtual executions for parallels + try { + for (const [key, state] of bs.entries()) { + const mapping = debugContext?.parallelBlockMapping?.get(key as any) + if (mapping && mapping.originalBlockId === baseId && state?.output) { + collected.push(state.output as any) + } + } + } catch {} + } + return collected + } + + // Add loop/parallel special variables if block is inside a loop or parallel + const addLoopParallelVariables = () => { + if (!debugContext) return + + // Check if focused block is inside a loop + for (const [loopId, loop] of Object.entries(currentWorkflow.loops || {})) { + if ((loop as any).nodes?.includes(focusedBlockId)) { + // Add loop.item and loop.index references + const loopItem = debugContext.loopItems?.get(loopId) + const loopIndex = debugContext.loopIterations?.get(loopId) + const loopItems = debugContext.loopItems?.get(`${loopId}_items`) + + if (loopItem !== undefined) { + entries.push({ ref: '', value: loopItem }) + } + if (loopIndex !== undefined) { + entries.push({ ref: '', value: loopIndex }) + } + if (loopItems !== undefined) { + entries.push({ ref: '', value: loopItems }) + } + + // Also add references for the loop block itself if it has executed + const loopBlock = blockById.get(loopId) + if (loopBlock) { + const loopName = normalizeBlockName(getDisplayName(loopBlock)) + if (loopItem !== undefined) { + entries.push({ ref: `<${loopName}.item>`, value: loopItem }) + } + if (loopIndex !== undefined) { + entries.push({ ref: `<${loopName}.index>`, value: loopIndex }) + } + if (loopItems !== undefined) { + entries.push({ ref: `<${loopName}.items>`, value: loopItems }) + } + } + } + } + + // Check if focused block is inside a parallel + for (const [parallelId, parallel] of Object.entries(currentWorkflow.parallels || {})) { + if ((parallel as any).nodes?.includes(focusedBlockId)) { + // Check for virtual block execution to get iteration info + const parallelState = debugContext.parallelExecutions?.get(parallelId) + if (parallelState) { + // Get current iteration context + const currentVirtualId = debugContext.currentVirtualBlockId + if (currentVirtualId) { + const mapping = debugContext.parallelBlockMapping?.get(currentVirtualId) + if (mapping) { + const iterationIndex = mapping.iterationIndex + const parallelItems = debugContext.loopItems?.get(`${parallelId}_items`) + const parallelItem = parallelItems + ? Array.isArray(parallelItems) + ? parallelItems[iterationIndex] + : Object.values(parallelItems)[iterationIndex] + : undefined + + if (parallelItem !== undefined) { + entries.push({ ref: '', value: parallelItem }) + } + entries.push({ ref: '', value: iterationIndex }) + if (parallelItems !== undefined) { + entries.push({ ref: '', value: parallelItems }) + } + + // Also add references for the parallel block itself + const parallelBlock = blockById.get(parallelId) + if (parallelBlock) { + const parallelName = normalizeBlockName(getDisplayName(parallelBlock)) + if (parallelItem !== undefined) { + entries.push({ ref: `<${parallelName}.item>`, value: parallelItem }) + } + entries.push({ ref: `<${parallelName}.index>`, value: iterationIndex }) + if (parallelItems !== undefined) { + entries.push({ ref: `<${parallelName}.items>`, value: parallelItems }) + } + } + } + } + } + } + } + } + + for (const id of accessibleIds) { + const blk = blockById.get(id) + if (!blk) continue + + const allowedPathsSet = new Set(getAccessiblePathsForBlock(id)) + if (allowedPathsSet.size === 0) continue + + const displayName = getDisplayName(blk) + const normalizedName = normalizeBlockName(displayName) + + // Gather executed outputs (direct and virtual) + const executedOutputs = collectExecutedOutputs(id) + + // Flatten helper over multiple outputs with last-wins per path + const pathToValue = new Map() + const flatten = (obj: any, prefix = ''): Array<{ path: string; value: any }> => { + if (obj == null || typeof obj !== 'object') return [] + const items: Array<{ path: string; value: any }> = [] + for (const [k, v] of Object.entries(obj)) { + const current = prefix ? `${prefix}.${k}` : k + if (v && typeof v === 'object' && !Array.isArray(v)) { + if (allowedPathsSet.has(current)) items.push({ path: current, value: v }) + items.push(...flatten(v, current)) + } else { + if (allowedPathsSet.has(current)) items.push({ path: current, value: v }) + } + } + return items + } + + for (const out of executedOutputs) { + const pairs = flatten(out) + for (const { path, value } of pairs) { + pathToValue.set(path, value) + } + } + + for (const [path, value] of pathToValue.entries()) { + entries.push({ ref: `<${normalizedName}.${path}>`, value }) + } + } + + // Add loop/parallel context variables + addLoopParallelVariables() + + // Sort for stable UI (by ref) + entries.sort((a, b) => a.ref.localeCompare(b.ref)) + return entries + }, [ + focusedBlockId, + currentWorkflow.edges, + currentWorkflow.loops, + currentWorkflow.parallels, + starterId, + blockById, + executionVersion, + debugContext, + ]) + + // Compute all possible refs from accessible blocks regardless of execution state + const allPossibleVariableRefs = useMemo(() => { + if (!focusedBlockId) return new Set() + + const normalizeBlockName = (name: string) => (name || '').replace(/\s+/g, '').toLowerCase() + const edges = currentWorkflow.edges || [] + const accessibleIds = new Set( + BlockPathCalculator.findAllPathNodes(edges, focusedBlockId) + ) + if (starterId && starterId !== focusedBlockId) accessibleIds.add(starterId) + + const refs = new Set() + + const getAccessiblePathsForBlock = (blockId: string): string[] => { + const blk = blockById.get(blockId) + if (!blk) return [] + const cfg = getBlock(blk.type) + if (!cfg) return [] + + const getSubBlockValue = (id: string, property: string): any => { + return useSubBlockStore.getState().getValue(id, property) + } + + const generateOutputPaths = (outputs: Record, prefix = ''): string[] => { + const paths: string[] = [] + for (const [key, value] of Object.entries(outputs || {})) { + const current = prefix ? `${prefix}.${key}` : key + if (typeof value === 'string') { + paths.push(current) + } else if (value && typeof value === 'object') { + if ('type' in value && typeof (value as any).type === 'string') { + paths.push(current) + if ((value as any).type === 'object' && (value as any).properties) { + paths.push(...generateOutputPaths((value as any).properties, current)) + } else if ((value as any).type === 'array' && (value as any).items?.properties) { + paths.push(...generateOutputPaths((value as any).items.properties, current)) + } + } else { + paths.push(...generateOutputPaths(value as Record, current)) + } + } else { + paths.push(current) + } + } + return paths + } + + // Response format overrides + const responseFormatValue = getSubBlockValue(blockId, 'responseFormat') + const responseFormat = parseResponseFormatSafely(responseFormatValue, blockId) + if (responseFormat) { + const fields = extractFieldsFromSchema(responseFormat) + if (fields.length > 0) return fields.map((f: any) => f.name) + } + + if (blk.type === 'evaluator') { + const metricsValue = getSubBlockValue(blockId, 'metrics') + if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) { + const valid = metricsValue.filter((m: { name?: string }) => m?.name) + return valid.map((m: { name: string }) => m.name.toLowerCase()) + } + return generateOutputPaths(cfg.outputs || {}) + } + + if (blk.type === 'starter') { + const startWorkflowValue = getSubBlockValue(blockId, 'startWorkflow') + if (startWorkflowValue === 'chat') { + return ['input', 'conversationId', 'files'] + } + const inputFormatValue = getSubBlockValue(blockId, 'inputFormat') + if (inputFormatValue && Array.isArray(inputFormatValue)) { + return inputFormatValue + .filter((f: { name?: string }) => f.name && f.name.trim() !== '') + .map((f: { name: string }) => f.name) + } + return [] + } + + if (blk.triggerMode && cfg.triggers?.enabled) { + const triggerId = cfg?.triggers?.available?.[0] + const firstTrigger = triggerId ? getTrigger(triggerId) : getTriggersByProvider(blk.type)[0] + if (firstTrigger?.outputs) { + return generateOutputPaths(firstTrigger.outputs) + } + } + + const operationValue = getSubBlockValue(blockId, 'operation') + if (operationValue && cfg?.tools?.config?.tool) { + try { + const toolId = cfg.tools.config.tool({ operation: operationValue }) + const toolConfig = toolId ? getTool(toolId) : null + if (toolConfig?.outputs) return generateOutputPaths(toolConfig.outputs) + } catch {} + } + + return generateOutputPaths(cfg.outputs || {}) + } + + for (const id of accessibleIds) { + const blk = blockById.get(id) + if (!blk) continue + const displayName = getDisplayName(blk) + const normalizedName = normalizeBlockName(displayName) + const paths = getAccessiblePathsForBlock(id) + for (const path of paths) refs.add(`<${normalizedName}.${path}>`) + } + + return refs + }, [focusedBlockId, currentWorkflow.edges, starterId, blockById]) + + // Filter output variables based on whether they're referenced in the input + const filteredOutputVariables = useMemo(() => { + // Build map of available executed entries for quick lookup + const availableVarsMap = new Map(outputVariableEntries.map((entry) => [entry.ref, entry])) + + if (!scopedVariables) { + // Show all possible upstream refs; mark as resolved if present in executed outputs + const result: Array<{ ref: string; value: any; resolved: boolean }> = [] + allPossibleVariableRefs.forEach((ref) => { + const available = availableVarsMap.get(ref) + if (available) { + result.push({ ...available, resolved: true }) + } else { + result.push({ ref, value: undefined, resolved: false }) + } + }) + result.sort((a, b) => { + if (a.resolved !== b.resolved) return a.resolved ? -1 : 1 + return a.ref.localeCompare(b.ref) + }) + return result + } + + // Get the JSON string of visible subblock values to search for references + const inputValuesStr = JSON.stringify(visibleSubblockValues) + + // Extract all variable references from the input using regex + // Matches patterns like or + const referencePattern = /<([^>]+)>/g + const referencedVars = new Set() + let match + while ((match = referencePattern.exec(inputValuesStr)) !== null) { + const fullMatch = match[0] // Full reference including < > + const innerContent = match[1] // Content between < > + + // Exclude workflow variable references (pattern: variable.something) + if (!innerContent.startsWith('variable.')) { + referencedVars.add(fullMatch) + } + } + + // Build the final list with both resolved and unresolved variables + const result: Array<{ ref: string; value: any; resolved: boolean }> = [] + + // Add all referenced variables (excluding workflow variables) + for (const ref of referencedVars) { + const available = availableVarsMap.get(ref) + if (available) { + // Variable is resolved (has a value) + result.push({ ...available, resolved: true }) + } else { + // Variable is unresolved (referenced but no value yet) + result.push({ ref, value: undefined, resolved: false }) + } + } + + // Sort: resolved first, then unresolved, then alphabetically by ref + result.sort((a, b) => { + if (a.resolved !== b.resolved) { + return a.resolved ? -1 : 1 + } + return a.ref.localeCompare(b.ref) + }) + return result + }, [ + outputVariableEntries, + scopedVariables, + visibleSubblockValues, + executionVersion, + allPossibleVariableRefs, + ]) + + // Filter workflow variables based on whether they're referenced in the input + const filteredWorkflowVariables = useMemo(() => { + // Get all workflow variables from the store + const storeVariables = workflowVariablesFromStore + + if (!scopedVariables) { + // Show all workflow variables from the store + return storeVariables.map((variable) => ({ + id: variable.id, + name: variable.name, + value: variable.value, + type: variable.type, + })) + } + + // For scoped view, look at the entire focused block's data + // This includes all subBlocks values, not just the visible ones + if (!focusedBlock) { + return [] + } + + // Get all subblock values from the store for this block + const allSubBlockValues: Record = {} + if (focusedBlockId && activeWorkflowId) { + const allBlocks = useWorkflowStore.getState().blocks + const merged = mergeSubblockState(allBlocks, activeWorkflowId, focusedBlockId)[focusedBlockId] + const stateToUse = merged?.subBlocks || {} + + // Extract all values from subBlocks + for (const [key, subBlock] of Object.entries(stateToUse)) { + if (subBlock && typeof subBlock === 'object' && 'value' in subBlock) { + allSubBlockValues[key] = subBlock.value + } + } + } + + // Search for workflow variable references in all subblock values + const blockDataStr = JSON.stringify(allSubBlockValues) + + // Extract workflow variable references using pattern + const variablePattern = /]+)>/g + const referencedVarNames = new Set() + let match + while ((match = variablePattern.exec(blockDataStr)) !== null) { + referencedVarNames.add(match[1]) // Add just the variable name part + } + + // Filter workflow variables to only those referenced + return storeVariables + .filter((variable) => { + // Normalize the variable name (remove spaces) to match how it's referenced + const normalizedName = (variable.name || '').replace(/\s+/g, '') + return referencedVarNames.has(normalizedName) + }) + .map((variable) => ({ + id: variable.id, + name: variable.name, + value: variable.value, + type: variable.type, + })) + }, [workflowVariablesFromStore, scopedVariables, focusedBlock, focusedBlockId, activeWorkflowId]) + + // Filter environment variables based on whether they're referenced in the input + const filteredEnvVariables = useMemo(() => { + // Helper function to recursively extract env var references from any value + const extractEnvVarReferences = (value: any, fieldName?: string): Set => { + const refs = new Set() + + if (typeof value === 'string') { + // Check if this is an API key field (by field name) + const isApiKeyField = + fieldName && + (fieldName.toLowerCase().includes('apikey') || + fieldName.toLowerCase().includes('api_key') || + fieldName.toLowerCase().includes('secretkey') || + fieldName.toLowerCase().includes('secret_key') || + fieldName.toLowerCase().includes('accesskey') || + fieldName.toLowerCase().includes('access_key') || + fieldName.toLowerCase().includes('token')) + + // Check if entire string is just {{ENV_VAR}} + const isExplicitEnvVar = value.trim().match(/^\{\{[^{}]+\}\}$/) + + // Check for env vars in specific contexts (Bearer tokens, URLs, headers, etc.) + const hasProperContext = + /Bearer\s+\{\{[^{}]+\}\}/i.test(value) || + /Authorization:\s+Bearer\s+\{\{[^{}]+\}\}/i.test(value) || + /Authorization:\s+\{\{[^{}]+\}\}/i.test(value) || + /[?&]api[_-]?key=\{\{[^{}]+\}\}/i.test(value) || + /[?&]key=\{\{[^{}]+\}\}/i.test(value) || + /[?&]token=\{\{[^{}]+\}\}/i.test(value) || + /X-API-Key:\s+\{\{[^{}]+\}\}/i.test(value) || + /api[_-]?key:\s+\{\{[^{}]+\}\}/i.test(value) + + // Extract env vars if this field should be processed + if (isApiKeyField || isExplicitEnvVar || hasProperContext) { + const envPattern = /\{\{([^}]+)\}\}/g + let match + while ((match = envPattern.exec(value)) !== null) { + refs.add(match[1]) + } + } + } else if (Array.isArray(value)) { + // Recursively process arrays + value.forEach((item, index) => { + const itemRefs = extractEnvVarReferences( + item, + fieldName ? `${fieldName}[${index}]` : undefined + ) + itemRefs.forEach((ref) => refs.add(ref)) + }) + } else if (value && typeof value === 'object') { + // Recursively process objects + Object.entries(value).forEach(([key, val]) => { + const itemRefs = extractEnvVarReferences(val, key) + itemRefs.forEach((ref) => refs.add(ref)) + }) + } + + return refs + } + + if (!scopedVariables) { + // Show all environment variables that are referenced anywhere in the workflow + const allEnvVarRefs = new Set() + + for (const block of blocksList) { + if (activeWorkflowId) { + const allBlocks = useWorkflowStore.getState().blocks + const merged = mergeSubblockState(allBlocks, activeWorkflowId, block.id)[block.id] + const stateToUse = merged?.subBlocks || {} + + // Process each subblock value with its field name + for (const [key, subBlock] of Object.entries(stateToUse)) { + if (subBlock && typeof subBlock === 'object' && 'value' in subBlock) { + const refs = extractEnvVarReferences(subBlock.value, key) + refs.forEach((ref) => allEnvVarRefs.add(ref)) + } + } + } + } + + // Return only env vars that are referenced somewhere in the workflow + return Object.entries(allEnvVars).filter(([key]) => allEnvVarRefs.has(key)) + } + + // For scoped view, look at the entire focused block's data + if (!focusedBlock) { + return [] + } + + // Get all subblock values from the store for this block + const blockEnvVarRefs = new Set() + if (focusedBlockId && activeWorkflowId) { + const allBlocks = useWorkflowStore.getState().blocks + const merged = mergeSubblockState(allBlocks, activeWorkflowId, focusedBlockId)[focusedBlockId] + const stateToUse = merged?.subBlocks || {} + + // Process each subblock value with its field name + for (const [key, subBlock] of Object.entries(stateToUse)) { + if (subBlock && typeof subBlock === 'object' && 'value' in subBlock) { + const refs = extractEnvVarReferences(subBlock.value, key) + refs.forEach((ref) => blockEnvVarRefs.add(ref)) + } + } + } + + // Filter environment variables to only those referenced + return Object.entries(allEnvVars).filter(([key]) => blockEnvVarRefs.has(key)) + }, [allEnvVars, scopedVariables, focusedBlock, focusedBlockId, activeWorkflowId, blocksList]) + + // Reset hasStartedRef when debug mode is deactivated + useEffect(() => { + if (!isDebugging) { + hasStartedRef.current = false + setPanelFocusedBlockId(null) + } + }, [isDebugging, setPanelFocusedBlockId]) + + if (!isDebugging) { + return ( +
+
+
+ +
+

Debug mode inactive

+

+ Enable debug mode to step through workflow execution +

+
+
+ ) + } + + // Step handler: handles both initial and subsequent steps + const handleStep = async () => { + if (!hasStartedRef.current && !debugContext) { + hasStartedRef.current = true + if (isChatMode) { + const text = chatMessage.trim() + if (!text) { + hasStartedRef.current = false + return + } + await handleRunWorkflow({ input: text, conversationId: crypto.randomUUID() }, true) + } else { + await handleRunWorkflow(undefined, true) + } + return + } + + await handleStepDebug() + } + + // Restart handler: reset to initial state without starting execution + const handleRestart = async () => { + // Do not toggle debug mode off; just reset execution/debug state + hasStartedRef.current = false + lastFocusedIdRef.current = null + setBreakpointId(null) + setExecutingBlockIds(new Set()) + setActiveBlocks(new Set()) + setPendingBlocks([]) + setDebugContext(null) + setPanelFocusedBlockId(starterId || null) + // Ensure executor is cleared so next Step re-initializes fresh + useExecutionStore.getState().setExecutor(null) + // Mark starter as current pending for UI so it shows as Current (no execution started) + if (starterId) { + setPendingBlocks([starterId]) + } + } + + // Resume-until-breakpoint handler + const handleResumeUntilBreakpoint = async () => { + // If not started yet, initialize the executor (same as first Step) + if (!useExecutionStore.getState().debugContext && !hasStartedRef.current) { + hasStartedRef.current = true + if (isChatMode) { + const text = chatMessage.trim() + if (!text) { + hasStartedRef.current = false + return + } + await handleRunWorkflow({ input: text, conversationId: crypto.randomUUID() }, true) + } else { + await handleRunWorkflow(undefined, true) + } + // Wait for initialization to populate executor/debugContext/pendingBlocks + const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)) + let attempts = 0 + while (attempts < 40) { + // ~2s max + const st = useExecutionStore.getState() + if (st.executor && st.debugContext && Array.isArray(st.pendingBlocks)) break + await wait(50) + attempts++ + } + } + + // Use freshest store state after init + let exec = useExecutionStore.getState().executor + let ctx = useExecutionStore.getState().debugContext + let pend = [...useExecutionStore.getState().pendingBlocks] + + if (!exec || !ctx) return + + try { + let iteration = 0 + const maxIterations = 1000 + const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)) + + while (iteration < maxIterations) { + // Refresh latest state each iteration to avoid stale refs + const st = useExecutionStore.getState() + exec = st.executor + ctx = st.debugContext + pend = [...st.pendingBlocks] + if (!exec || !ctx) break + + // Determine executable set + const executable = breakpointId + ? pend.filter((id) => { + if (id === breakpointId) return false + try { + const mapping = ctx?.parallelBlockMapping?.get(id) + if (mapping && mapping.originalBlockId === breakpointId) return false + } catch {} + return true + }) + : pend + if (executable.length === 0) break + + setExecutingBlockIds(new Set(executable)) + const result = await exec.continueExecution(executable, ctx) + setExecutingBlockIds(new Set()) + + if (result?.metadata?.context) { + setDebugContext(result.metadata.context) + } + if (result?.metadata?.pendingBlocks) { + setPendingBlocks(result.metadata.pendingBlocks) + } else { + break + } + if (!result?.metadata?.isDebugSession) break + + iteration++ + // allow UI/state to settle + await wait(10) + } + } catch (e) { + // Swallow to avoid double error surfaces in UI + } + } + + const getStatusIcon = () => { + if (isFocusedErrored) return + if (isFocusedCurrent) return + if (isFocusedExecuted) return + return + } + + const getStatusText = () => { + if (isFocusedErrored) return 'Error' + if (isFocusedCurrent) return 'Current' + if (isFocusedExecuted) return 'Executed' + return 'Pending' + } + + const getResolutionIcon = () => { + const resolvedCount = filteredOutputVariables.filter((v) => v.resolved).length + const unresolvedCount = filteredOutputVariables.filter((v) => !v.resolved).length + + if (unresolvedCount === 0) { + // All resolved - green check + return + } + if (isFocusedCurrent) { + // Current block with unresolved variables - red X + return + } + // Not current but has unresolved variables - yellow dot + return + } + + return ( +
+ {/* Controls Section */} +
+ {isChatMode && !hasStartedRef.current && ( +
+