Bläddra i källkod

Fix expression evaluation for loop body nodes and improve drag-drop expressions

Backend changes:
- Add expression evaluation for loop body nodes in workflow_engine.cpp
- Merge iteration results with main results for expression resolution
- Expressions like {{data.data.id}} now properly evaluate inside loops

Frontend changes:
- Fix WebSocket connection to use Vite proxy in development mode
- Add targetInput to FieldInfo interface for correct expression path generation
- Update AvailableDataPanel to prefix expressions with connection's target_input
- This ensures dragged expressions resolve to correct paths (e.g., {{data.data.data.id}})
- Fix execution viewer banner and viewing mode state handling
- Add vite/client types to tsconfig for import.meta.env support
fszontagh 6 månader sedan
förälder
incheckning
ff0be91483

+ 41 - 1
src/runner/workflow_engine.cpp

@@ -290,6 +290,13 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
                 continue;
             }
 
+            // Skip nodes that were already executed (e.g., loop body nodes)
+            // They have results stored during loop execution
+            if (result.node_results.contains(node_id)) {
+                LOG_DEBUG("Node {} already has results, skipping in main loop", node_id);
+                continue;
+            }
+
             // Skip other trigger nodes when a specific trigger is selected
             auto node_def = registry_.getNode(node->type);
             if (node_def && node_def->is_trigger && node_id != trigger_node_id) {
@@ -1159,7 +1166,18 @@ bool WorkflowEngine::executeLoopBody(
                 });
             }
 
-            auto body_result = executeNode(*body_node, node_input, result.execution_id, workflow);
+            // Merge main results with iteration results for expression resolution
+            // This allows body nodes to reference earlier nodes in the same iteration
+            auto merged_results = result.node_results;
+            for (const auto& [key, value] : iteration_results) {
+                merged_results[key] = value;
+            }
+
+            // Evaluate expressions in body node config before execution
+            WorkflowNode evaluated_body_node = *body_node;
+            evaluated_body_node.config = evaluateExpressions(body_node->config, node_input, merged_results, workflow);
+
+            auto body_result = executeNode(evaluated_body_node, node_input, result.execution_id, workflow);
             iteration_results[body_node_id] = body_result;
 
             // Store in main results with iteration suffix
@@ -1218,6 +1236,28 @@ bool WorkflowEngine::executeLoopBody(
     // Copy results back (ctx was passed by const ref, but we used a mutable copy)
     const_cast<LoopContext&>(loop_ctx).results = ctx.results;
 
+    // Store base results for body nodes so main loop will skip them
+    // (main loop checks result.node_results.contains(node_id))
+    for (const auto& body_node_id : sorted_body) {
+        // Find the most recent iteration result for this node
+        NodeExecutionResult base_result;
+        bool found = false;
+        for (size_t i = ctx.items.size(); i > 0; --i) {
+            std::string iter_key = body_node_id + "_iter_" + std::to_string(i - 1);
+            auto it = result.node_results.find(iter_key);
+            if (it != result.node_results.end()) {
+                base_result = it->second;
+                found = true;
+                break;
+            }
+        }
+        if (found) {
+            // Mark as executed in loop so main loop skips it
+            base_result.output["_executedInLoop"] = true;
+            result.node_results[body_node_id] = base_result;
+        }
+    }
+
     return all_succeeded;
 }
 

+ 13 - 5
webui/src/api/client.ts

@@ -88,13 +88,21 @@ export class WebSocketClient {
 
     const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
 
-    // WebSocket server runs on HTTP port + 1
-    const httpPort = parseInt(window.location.port) || (window.location.protocol === 'https:' ? 443 : 80)
-    const wsPort = httpPort + 1
-    const wsHost = window.location.hostname + ':' + wsPort
+    // In development (Vite), use /ws path which is proxied to the backend
+    // In production, WebSocket server runs on HTTP port + 1
+    let wsUrl: string
+    if (import.meta.env.DEV) {
+      // Use Vite proxy - connects to same host/port with /ws path
+      wsUrl = `${protocol}//${window.location.host}/ws`
+    } else {
+      // Production: WebSocket server on HTTP port + 1
+      const httpPort = parseInt(window.location.port) || (window.location.protocol === 'https:' ? 443 : 80)
+      const wsPort = httpPort + 1
+      wsUrl = `${protocol}//${window.location.hostname}:${wsPort}`
+    }
 
     try {
-      this.ws = new WebSocket(`${protocol}//${wsHost}`)
+      this.ws = new WebSocket(wsUrl)
     } catch (e) {
       console.error('Failed to create WebSocket:', e)
       this.handleReconnect()

+ 10 - 1
webui/src/components/AvailableDataPanel.tsx

@@ -8,6 +8,8 @@ export interface FieldInfo {
   nodeId: string
   displayName?: string
   children?: FieldInfo[]
+  isDirectUpstream?: boolean  // true if this is an immediate predecessor node
+  targetInput?: string  // the connection's target_input name (for direct upstream)
 }
 
 interface AvailableDataPanelProps {
@@ -45,7 +47,14 @@ function FieldItem({ field, depth, onInsertExpression }: FieldItemProps) {
   const [copied, setCopied] = useState(false)
 
   const hasChildren = field.children && field.children.length > 0
-  const expression = `{{$node["${field.nodeName}"].${field.path}}}`
+  // Both direct and non-direct upstream use {{}} braces for evaluation
+  // Direct upstream: path prefixed with targetInput (evaluated against input)
+  // Non-direct upstream: $node accessor to reference specific node output
+  const expression = field.isDirectUpstream
+    ? field.targetInput
+      ? `{{${field.targetInput}.${field.path}}}`  // Direct upstream with targetInput prefix
+      : `{{${field.path}}}`                        // Fallback (rare case)
+    : `{{$node["${field.nodeName}"].${field.path}}}`
 
   const handleCopy = () => {
     navigator.clipboard.writeText(expression)

+ 7 - 2
webui/src/components/ExpressionInput.tsx

@@ -7,6 +7,7 @@ export interface AvailableField {
   nodeName: string
   nodeId: string
   displayName?: string
+  isDirectUpstream?: boolean  // true if this is an immediate predecessor node
 }
 
 interface ExpressionInputProps {
@@ -112,8 +113,12 @@ export function ExpressionInput({
     const { inside, start } = isInsideExpression(value, cursorPosition)
     if (!inside) return
 
-    // Build the expression
-    const expression = `$node["${field.nodeName}"].${field.path}`
+    // Build the expression based on whether this is a direct upstream node
+    // Direct upstream: raw path (no $node accessor)
+    // Non-direct upstream: full expression with node accessor
+    const expression = field.isDirectUpstream
+      ? field.path
+      : `$node["${field.nodeName}"].${field.path}`
 
     // Find the end of current filter text
     const beforeFilter = value.slice(0, start)

+ 2 - 2
webui/src/components/Layout.tsx

@@ -25,7 +25,7 @@ export default function Layout() {
   ]
 
   return (
-    <div className="min-h-screen bg-gray-50 dark:bg-slate-900 flex transition-colors">
+    <div className="h-screen bg-gray-50 dark:bg-slate-900 flex transition-colors overflow-hidden">
       {/* Mobile sidebar backdrop */}
       {sidebarOpen && (
         <div
@@ -114,7 +114,7 @@ export default function Layout() {
         </header>
 
         {/* Page content */}
-        <main className="flex-1 overflow-auto">
+        <main className="flex-1 overflow-hidden">
           <Outlet />
         </main>
       </div>

+ 1 - 1
webui/src/components/workflow/ExecutionResultsPanel.tsx

@@ -172,7 +172,7 @@ export function ExecutionResultsPanel({
 
   return (
     <div
-      className="w-1/3 h-full border-l border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 flex flex-col overflow-hidden"
+      className="w-1/3 h-full border-l border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 flex flex-col overflow-hidden isolate"
     >
       <div className="flex-shrink-0 p-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
         <h2 className="font-semibold text-gray-900 dark:text-gray-100">Execution Results</h2>

+ 1 - 0
webui/src/components/workflow/ExecutionViewerBanner.tsx

@@ -32,6 +32,7 @@ export function ExecutionViewerBanner({
           <Eye className="w-5 h-5 opacity-80" />
           <div className="flex items-center gap-2">
             <span className="font-medium">Viewing Execution</span>
+            <span className="px-2 py-0.5 bg-white/20 rounded text-xs font-medium">Read-Only Snapshot</span>
             <span className="text-blue-200 dark:text-blue-300">|</span>
             <div className="flex items-center gap-1.5 text-sm text-blue-100 dark:text-blue-200">
               <Clock className="w-4 h-4" />

+ 1 - 1
webui/src/pages/CredentialsPage.tsx

@@ -137,7 +137,7 @@ export default function CredentialsPage() {
   }
 
   return (
-    <div className="p-6">
+    <div className="h-full overflow-auto p-6">
       {/* Header */}
       <div className="flex items-center justify-between mb-6">
         <div>

+ 1 - 1
webui/src/pages/DatabasePage.tsx

@@ -353,7 +353,7 @@ export default function DatabasePage() {
   }
 
   return (
-    <div className="h-[calc(100vh-4rem)] lg:h-screen flex overflow-hidden">
+    <div className="h-full flex overflow-hidden">
       {/* Collections sidebar */}
       <div className="w-64 h-full bg-white dark:bg-slate-800 border-r border-gray-200 dark:border-slate-700 flex flex-col flex-shrink-0">
         <div className="h-14 px-4 flex items-center justify-between border-b border-gray-200 dark:border-slate-700">

+ 1 - 1
webui/src/pages/ExecutionsPage.tsx

@@ -56,7 +56,7 @@ export default function ExecutionsPage() {
   }
 
   return (
-    <div className="p-6">
+    <div className="h-full overflow-auto p-6">
       <div className="flex items-center justify-between mb-6">
         <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Executions</h1>
         <button

+ 1 - 1
webui/src/pages/SettingsPage.tsx

@@ -16,7 +16,7 @@ export default function SettingsPage() {
   })
 
   return (
-    <div className="p-6 max-w-4xl">
+    <div className="h-full overflow-auto p-6 max-w-4xl">
       <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">Settings</h1>
 
       {/* Profile section */}

+ 207 - 52
webui/src/pages/WorkflowEditorPage.tsx

@@ -243,9 +243,9 @@ function WorkflowEditorInner() {
 
   // Handle viewing an execution in read-only mode
   const handleViewExecution = useCallback((execution: ExecutionDetail) => {
-    setViewingExecution(true)
     const { currentNodes, currentConnections } = getCurrentWorkflowData()
     pinExecution(execution, currentNodes, currentConnections)
+    setViewingExecution(true) // Must be called AFTER pinExecution (which resets it to false)
     setShowExecutionList(false)
     showToast('info', 'Viewing execution results')
   }, [getCurrentWorkflowData, pinExecution, setViewingExecution, showToast])
@@ -328,6 +328,9 @@ function WorkflowEditorInner() {
   const executeWorkflow = useCallback((triggerNodeId?: string) => {
     if (!id) return
 
+    // Set pending marker BEFORE HTTP request to capture early WebSocket events
+    pendingExecIdRef.current = 'pending'
+
     setExecutionState({
       executionId: null,
       status: 'running',
@@ -346,6 +349,7 @@ function WorkflowEditorInner() {
       }))
       showToast('info', `Execution started: ${result.executionId}`)
     }).catch((error) => {
+      pendingExecIdRef.current = null
       setExecutionState((prev) => ({
         ...prev,
         status: 'failed',
@@ -364,6 +368,9 @@ function WorkflowEditorInner() {
   const executeNode = useCallback((targetNodeId: string) => {
     if (!id) return
 
+    // Set pending marker BEFORE HTTP request to capture early WebSocket events
+    pendingExecIdRef.current = 'pending'
+
     setExecutingNodeId(targetNodeId)
     setExecutionState({
       executionId: null,
@@ -383,6 +390,7 @@ function WorkflowEditorInner() {
       }))
       showToast('info', `Running node: ${targetNodeId}`)
     }).catch((error) => {
+      pendingExecIdRef.current = null
       setExecutionState((prev) => ({
         ...prev,
         status: 'failed',
@@ -393,10 +401,8 @@ function WorkflowEditorInner() {
     })
   }, [id, showToast])
 
-  // WebSocket subscription for execution updates
+  // WebSocket subscription for execution updates - subscribe on mount, filter in handler
   useEffect(() => {
-    if (executionState.status !== 'running') return
-
     const handleExecutionEvent = (data: any) => {
       const channel = data._channel || ''
       const parts = channel.split('.')
@@ -405,11 +411,15 @@ function WorkflowEditorInner() {
       const eventExecId = parts[1]
       const eventType = parts.slice(2).join('.')
 
-      const targetExecId = executionState.executionId || pendingExecIdRef.current
-      if (targetExecId && eventExecId !== targetExecId) return
+      // Only process events when we're running or have a pending execution
+      const targetExecId = pendingExecIdRef.current
+      if (!targetExecId) return // Not running any execution
 
-      if (!executionState.executionId && !pendingExecIdRef.current) {
+      // If we're in 'pending' state, accept first event and capture execution ID
+      if (targetExecId === 'pending') {
         pendingExecIdRef.current = eventExecId
+      } else if (eventExecId !== targetExecId) {
+        return // Different execution
       }
 
       if (eventType === 'node_running') {
@@ -459,19 +469,30 @@ function WorkflowEditorInner() {
         }))
         setExpandedNodes((prev) => new Set([...prev, data.nodeId]))
       } else if (eventType === 'node_skipped') {
-        setExecutionState((prev) => ({
-          ...prev,
-          executionId: eventExecId,
-          nodeStates: {
-            ...prev.nodeStates,
-            [data.nodeId]: {
-              status: 'skipped',
-              output: { reason: data.reason || 'Inactive branch' },
-              completedAt: Date.now(),
+        setExecutionState((prev) => {
+          const existingState = prev.nodeStates[data.nodeId]
+          // Don't overwrite if this node already has meaningful execution state
+          // (iterations from loop, or completed/failed status)
+          if (existingState?.iterations && existingState.iterations.length > 0) {
+            return prev
+          }
+          if (existingState?.status === 'completed' || existingState?.status === 'failed') {
+            return prev
+          }
+          return {
+            ...prev,
+            executionId: eventExecId,
+            nodeStates: {
+              ...prev.nodeStates,
+              [data.nodeId]: {
+                status: 'skipped',
+                output: { reason: data.reason || 'Inactive branch' },
+                completedAt: Date.now(),
+              },
             },
-          },
-        }))
-      } else if (eventType === 'loop_node_running') {
+          }
+        })
+      } else if (eventType === 'loop_node_running' || eventType === 'loop.node.running') {
         setExecutionState((prev) => {
           const prevNodeState = prev.nodeStates[data.nodeId] || {}
           return {
@@ -488,7 +509,7 @@ function WorkflowEditorInner() {
             },
           }
         })
-      } else if (eventType === 'loop_node_completed') {
+      } else if (eventType === 'loop_node_completed' || eventType === 'loop.node.completed') {
         setExecutionState((prev) => {
           const prevNodeState = prev.nodeStates[data.nodeId] || {}
           const prevIterations = prevNodeState.iterations || []
@@ -518,7 +539,7 @@ function WorkflowEditorInner() {
           ...prev,
           [data.nodeId]: data.output,
         }))
-      } else if (eventType === 'loop_node_failed') {
+      } else if (eventType === 'loop_node_failed' || eventType === 'loop.node.failed') {
         setExecutionState((prev) => {
           const prevNodeState = prev.nodeStates[data.nodeId] || {}
           const prevIterations = prevNodeState.iterations || []
@@ -544,7 +565,32 @@ function WorkflowEditorInner() {
           }
         })
         setExpandedNodes((prev) => new Set([...prev, data.nodeId]))
-      } else if (eventType === 'loop_iteration_start') {
+      } else if (eventType === 'loop_node_skipped' || eventType === 'loop.node.skipped') {
+        // Handle skipped iterations in loop body nodes
+        setExecutionState((prev) => {
+          const prevNodeState = prev.nodeStates[data.nodeId] || {}
+          const prevIterations = prevNodeState.iterations || []
+          const newIteration: IterationResult = {
+            index: data.iteration,
+            status: 'skipped',
+            output: data.output,
+          }
+          return {
+            ...prev,
+            executionId: eventExecId,
+            nodeStates: {
+              ...prev.nodeStates,
+              [data.nodeId]: {
+                ...prevNodeState,
+                status: 'completed', // Overall status stays completed (skipped iteration is normal)
+                completedAt: Date.now(),
+                iteration: data.iteration,
+                iterations: [...prevIterations, newIteration],
+              },
+            },
+          }
+        })
+      } else if (eventType === 'loop_iteration_start' || eventType === 'loop.iteration.start') {
         setExecutionState((prev) => ({
           ...prev,
           executionId: eventExecId,
@@ -583,6 +629,7 @@ function WorkflowEditorInner() {
         }))
         setExpandedNodes((prev) => new Set([...prev, data.nodeId]))
       } else if (eventType === 'completed') {
+        pendingExecIdRef.current = null // Clear pending marker
         setExecutionState((prev) => ({
           ...prev,
           executionId: eventExecId,
@@ -592,6 +639,7 @@ function WorkflowEditorInner() {
         setExecutingNodeId(null)
         showToast('success', 'Workflow execution completed')
       } else if (eventType === 'failed') {
+        pendingExecIdRef.current = null // Clear pending marker
         setExecutionState((prev) => ({
           ...prev,
           executionId: eventExecId,
@@ -614,9 +662,8 @@ function WorkflowEditorInner() {
     return () => {
       wsClient.unsubscribe(['executions.*'])
       wsClient.off('executions.*', handleExecutionEvent)
-      pendingExecIdRef.current = null
     }
-  }, [executionState.status, executionState.executionId, showToast])
+  }, [showToast]) // Subscribe on mount, unsubscribe on unmount
 
   const nodeDefs: NodeDefinition[] = nodeDefinitions?.nodes || []
   const nodeDefsMap = useMemo(() => {
@@ -625,6 +672,69 @@ function WorkflowEditorInner() {
     return map
   }, [nodeDefs])
 
+  // Compute viewed nodes and edges when in view mode (from workflow snapshot)
+  const { viewedNodes, viewedEdges } = useMemo(() => {
+    if (!isViewingExecution || !pinnedExecution?.workflowSnapshot) {
+      return { viewedNodes: null, viewedEdges: null }
+    }
+
+    // Convert snapshot nodes to ReactFlow format
+    const snapshotNodes = pinnedExecution.workflowSnapshot.nodes.map(n => {
+      const nodeDef = nodeDefsMap[n.type]
+      const outputs = nodeDef?.outputs?.length
+        ? nodeDef.outputs
+        : [{ name: 'main', displayName: 'Output', type: 'any' }]
+
+      return {
+        id: n.id,
+        type: 'workflowNode' as const,
+        position: n.position,
+        data: {
+          label: n.name || n.type,
+          type: n.type,
+          config: n.config,
+          isTrigger: nodeDef?.isTrigger || false,
+          icon: nodeDef?.icon,
+          outputs,
+          nodeId: n.id,
+          onExecute: () => {}, // No-op in view mode
+          onExecuteTrigger: () => {}, // No-op in view mode
+          executionState: effectiveExecutionState.nodeStates[n.id],
+        },
+        selected: false,
+      }
+    })
+
+    // Convert snapshot connections to ReactFlow edges
+    const snapshotEdges = pinnedExecution.workflowSnapshot.connections.map((c, idx) => {
+      let edgeColor = '#cbd5e1'
+      if (c.sourceOutput === 'true' || c.sourceOutput === 'loop') {
+        edgeColor = '#22c55e'
+      } else if (c.sourceOutput === 'false') {
+        edgeColor = '#ef4444'
+      } else if (c.sourceOutput === 'done') {
+        edgeColor = '#3b82f6'
+      }
+
+      return {
+        id: `e${idx}`,
+        source: c.sourceNodeId,
+        sourceHandle: c.sourceOutput,
+        target: c.targetNodeId,
+        targetHandle: c.targetInput,
+        type: 'smoothstep' as const,
+        style: { strokeWidth: 2, stroke: edgeColor },
+        animated: false,
+        label: c.sourceOutput !== 'main' ? c.sourceOutput : undefined,
+        labelStyle: { fill: edgeColor, fontWeight: 500, fontSize: 10 },
+        labelBgStyle: { fill: 'var(--color-bg)', fillOpacity: 0.9 },
+        zIndex: 1,
+      }
+    })
+
+    return { viewedNodes: snapshotNodes, viewedEdges: snapshotEdges }
+  }, [isViewingExecution, pinnedExecution, nodeDefsMap, effectiveExecutionState.nodeStates])
+
   // Get list of trigger nodes for multi-trigger selection
   const triggerNodes = useMemo(() => {
     return nodes
@@ -1284,12 +1394,24 @@ function WorkflowEditorInner() {
     const fields: FieldInfo[] = []
     const visited = new Set<string>()
 
+    // Get direct predecessor node IDs and their targetHandle (target_input) from edges
+    const directUpstreamEdges = edges.filter(e => e.target === nodeId)
+    const directUpstreamIds = new Set(directUpstreamEdges.map(e => e.source))
+    // Map from source node ID to targetHandle (for expression path prefix)
+    const directUpstreamTargetInputs = new Map<string, string>()
+    for (const edge of directUpstreamEdges) {
+      // Use targetHandle if present, otherwise default to 'data'
+      directUpstreamTargetInputs.set(edge.source, edge.targetHandle || 'data')
+    }
+
     const extractFieldsFromData = (
       data: any,
       prefix: string,
       nodeName: string,
       nodeIdSource: string,
-      depth: number = 0
+      depth: number = 0,
+      isDirectUpstream: boolean = false,
+      targetInput?: string
     ): FieldInfo[] => {
       const result: FieldInfo[] = []
       if (data === null || data === undefined) return result
@@ -1314,12 +1436,14 @@ function WorkflowEditorInner() {
             nodeName,
             nodeId: nodeIdSource,
             displayName: `[${i}]`,
+            isDirectUpstream,
+            targetInput,
           }
 
           if (fieldType === 'object' && item !== null) {
-            fieldInfo.children = extractFieldsFromData(item, path, nodeName, nodeIdSource, depth + 1)
+            fieldInfo.children = extractFieldsFromData(item, path, nodeName, nodeIdSource, depth + 1, isDirectUpstream, targetInput)
           } else if (fieldType === 'array') {
-            fieldInfo.children = extractFieldsFromData(item, path, nodeName, nodeIdSource, depth + 1)
+            fieldInfo.children = extractFieldsFromData(item, path, nodeName, nodeIdSource, depth + 1, isDirectUpstream, targetInput)
           }
 
           result.push(fieldInfo)
@@ -1331,6 +1455,8 @@ function WorkflowEditorInner() {
             nodeName,
             nodeId: nodeIdSource,
             displayName: `... (${data.length} total items)`,
+            isDirectUpstream,
+            targetInput,
           })
         }
       } else if (typeof data === 'object') {
@@ -1352,12 +1478,14 @@ function WorkflowEditorInner() {
             nodeName,
             nodeId: nodeIdSource,
             displayName: key,
+            isDirectUpstream,
+            targetInput,
           }
 
           if (fieldType === 'object' && value !== null) {
-            fieldInfo.children = extractFieldsFromData(value, path, nodeName, nodeIdSource, depth + 1)
+            fieldInfo.children = extractFieldsFromData(value, path, nodeName, nodeIdSource, depth + 1, isDirectUpstream, targetInput)
           } else if (fieldType === 'array' && value !== null) {
-            fieldInfo.children = extractFieldsFromData(value, path, nodeName, nodeIdSource, depth + 1)
+            fieldInfo.children = extractFieldsFromData(value, path, nodeName, nodeIdSource, depth + 1, isDirectUpstream, targetInput)
           }
 
           result.push(fieldInfo)
@@ -1370,7 +1498,9 @@ function WorkflowEditorInner() {
       props: Record<string, any>,
       prefix: string,
       nodeName: string,
-      nodeIdSource: string
+      nodeIdSource: string,
+      isDirectUpstream: boolean = false,
+      targetInput?: string
     ): FieldInfo[] => {
       const result: FieldInfo[] = []
       for (const [key, value] of Object.entries(props)) {
@@ -1385,6 +1515,8 @@ function WorkflowEditorInner() {
           nodeName,
           nodeId: nodeIdSource,
           displayName: (value as any).title || key,
+          isDirectUpstream,
+          targetInput,
         }
 
         if (fieldType === 'object' && (value as any).properties) {
@@ -1392,7 +1524,9 @@ function WorkflowEditorInner() {
             (value as any).properties,
             path,
             nodeName,
-            nodeIdSource
+            nodeIdSource,
+            isDirectUpstream,
+            targetInput
           )
         }
 
@@ -1429,6 +1563,10 @@ function WorkflowEditorInner() {
 
     for (const { node: sourceNode } of orderedNodes) {
       const nodeName = sourceNode.data.label || sourceNode.data.type
+      // Check if this source node is a direct upstream (immediate predecessor) of the target node
+      const isDirectUpstream = directUpstreamIds.has(sourceNode.id)
+      // Get the targetInput for direct upstream nodes
+      const targetInput = isDirectUpstream ? directUpstreamTargetInputs.get(sourceNode.id) : undefined
 
       const executionResult = lastExecutionResults[sourceNode.id]
       const pinnedResult = nodeChangeStatus[sourceNode.id] === 'unchanged'
@@ -1448,6 +1586,8 @@ function WorkflowEditorInner() {
                 nodeName,
                 nodeId: sourceNode.id,
                 displayName: 'currentIndex',
+                isDirectUpstream,
+                targetInput,
               },
               {
                 path: 'currentItem',
@@ -1455,8 +1595,10 @@ function WorkflowEditorInner() {
                 nodeName,
                 nodeId: sourceNode.id,
                 displayName: 'currentItem',
+                isDirectUpstream,
+                targetInput,
                 children: itemData !== null && typeof itemData === 'object'
-                  ? extractFieldsFromData(itemData, 'currentItem', nodeName, sourceNode.id)
+                  ? extractFieldsFromData(itemData, 'currentItem', nodeName, sourceNode.id, 0, isDirectUpstream, targetInput)
                   : undefined,
               },
               {
@@ -1465,15 +1607,17 @@ function WorkflowEditorInner() {
                 nodeName,
                 nodeId: sourceNode.id,
                 displayName: 'totalItems',
+                isDirectUpstream,
+                targetInput,
               },
             ]
 
             fields.push(...loopFields)
           } else {
-            fields.push(...extractFieldsFromData(dataSource, '', nodeName, sourceNode.id))
+            fields.push(...extractFieldsFromData(dataSource, '', nodeName, sourceNode.id, 0, isDirectUpstream, targetInput))
           }
         } else {
-          fields.push(...extractFieldsFromData(dataSource, '', nodeName, sourceNode.id))
+          fields.push(...extractFieldsFromData(dataSource, '', nodeName, sourceNode.id, 0, isDirectUpstream, targetInput))
         }
       } else {
         const nodeDef = nodeDefsMap[sourceNode.data.type]
@@ -1512,6 +1656,8 @@ function WorkflowEditorInner() {
                 nodeName,
                 nodeId: sourceNode.id,
                 displayName: 'currentIndex',
+                isDirectUpstream,
+                targetInput,
               },
               {
                 path: 'currentItem',
@@ -1519,8 +1665,10 @@ function WorkflowEditorInner() {
                 nodeName,
                 nodeId: sourceNode.id,
                 displayName: 'currentItem',
+                isDirectUpstream,
+                targetInput,
                 children: sampleItem !== null && typeof sampleItem === 'object'
-                  ? extractFieldsFromData(sampleItem, 'currentItem', nodeName, sourceNode.id)
+                  ? extractFieldsFromData(sampleItem, 'currentItem', nodeName, sourceNode.id, 0, isDirectUpstream, targetInput)
                   : undefined,
               },
               {
@@ -1529,6 +1677,8 @@ function WorkflowEditorInner() {
                 nodeName,
                 nodeId: sourceNode.id,
                 displayName: 'totalItems',
+                isDirectUpstream,
+                targetInput,
               },
             ]
 
@@ -1536,7 +1686,7 @@ function WorkflowEditorInner() {
           } else {
             const outputSchema = nodeDef.outputSchema as Record<string, any>
             if (outputSchema?.properties) {
-              fields.push(...extractFieldsFromSchema(outputSchema.properties, '', nodeName, sourceNode.id))
+              fields.push(...extractFieldsFromSchema(outputSchema.properties, '', nodeName, sourceNode.id, isDirectUpstream, targetInput))
             }
           }
         }
@@ -1554,6 +1704,7 @@ function WorkflowEditorInner() {
       type: f.type,
       nodeName: f.nodeName,
       displayName: f.displayName,
+      isDirectUpstream: f.isDirectUpstream,
     }))
   }, [getUpstreamOutputFields])
 
@@ -1607,7 +1758,7 @@ function WorkflowEditorInner() {
       />
 
       {/* Main content */}
-      <div className="flex-1 flex overflow-hidden">
+      <div className="flex-1 flex overflow-hidden min-h-0">
         {/* Node picker sidebar (left) */}
         {showNodePicker && (
           <NodePickerSidebar
@@ -1620,26 +1771,30 @@ function WorkflowEditorInner() {
         {/* Flow canvas */}
         <div className={`flex-1 overflow-hidden ${(showExecutionPanel || isViewingExecution || pinnedExecution) ? 'w-2/3' : 'w-full'}`}>
           <ReactFlow
-            nodes={nodes}
-            edges={edges}
+            nodes={isViewingExecution && viewedNodes ? viewedNodes : nodes}
+            edges={isViewingExecution && viewedEdges ? viewedEdges : edges}
             nodeTypes={nodeTypes}
             defaultEdgeOptions={{
               type: 'smoothstep',
               style: { strokeWidth: 2 },
             }}
             connectionLineType={ConnectionLineType.SmoothStep}
-            snapToGrid={true}
+            snapToGrid={!isViewingExecution}
             snapGrid={[20, 20]}
             // Multi-selection: Shift+click to add to selection, Ctrl/Cmd+drag to pan
-            selectionOnDrag={true}
+            selectionOnDrag={!isViewingExecution}
             selectionMode={SelectionMode.Partial}
             panOnDrag={[1, 2]} // Middle mouse button or right mouse for panning
             panOnScroll={true}
             panOnScrollMode={PanOnScrollMode.Free}
-            selectionKeyCode="Shift"
-            multiSelectionKeyCode="Shift"
+            selectionKeyCode={isViewingExecution ? null : "Shift"}
+            multiSelectionKeyCode={isViewingExecution ? null : "Shift"}
             panActivationKeyCode="Control"
-            onNodesChange={(changes) => {
+            // Disable editing when viewing execution snapshot
+            nodesDraggable={!isViewingExecution}
+            nodesConnectable={!isViewingExecution}
+            elementsSelectable={!isViewingExecution}
+            onNodesChange={isViewingExecution ? undefined : (changes) => {
               // Only mark as changed for actual modifications, not selection
               const hasActualChange = changes.some((c) => {
                 if (c.type === 'remove') return true
@@ -1652,23 +1807,23 @@ function WorkflowEditorInner() {
               }
               onNodesChange(changes)
             }}
-            onEdgesChange={(changes) => {
+            onEdgesChange={isViewingExecution ? undefined : (changes) => {
               // Only mark as changed for actual edge modifications
               if (changes.some((c) => c.type === 'remove')) {
                 setHasChanges(true)
               }
               onEdgesChange(changes)
             }}
-            onConnect={onConnect}
+            onConnect={isViewingExecution ? undefined : onConnect}
             onNodeClick={onNodeClick}
-            onNodeContextMenu={onNodeContextMenu}
-            onNodeDoubleClick={onNodeDoubleClick}
+            onNodeContextMenu={isViewingExecution ? undefined : onNodeContextMenu}
+            onNodeDoubleClick={isViewingExecution ? undefined : onNodeDoubleClick}
             onEdgeClick={onEdgeClick}
-            onEdgeContextMenu={onEdgeContextMenu}
+            onEdgeContextMenu={isViewingExecution ? undefined : onEdgeContextMenu}
             onPaneClick={onPaneClick}
             fitView
             deleteKeyCode={null}
-            edgesUpdatable
+            edgesUpdatable={!isViewingExecution}
           >
             <Background variant={BackgroundVariant.Dots} gap={20} size={1} />
             <Controls />
@@ -1705,8 +1860,8 @@ function WorkflowEditorInner() {
         {(showExecutionPanel || isViewingExecution || pinnedExecution) && (
           <ExecutionResultsPanel
             executionState={effectiveExecutionState}
-            nodes={nodes}
-            edges={edges}
+            nodes={isViewingExecution && viewedNodes ? viewedNodes : nodes}
+            edges={isViewingExecution && viewedEdges ? viewedEdges : edges}
             expandedNodes={expandedNodes}
             selectedIterations={selectedIterations}
             onClose={() => {

+ 1 - 1
webui/src/pages/WorkflowsPage.tsx

@@ -72,7 +72,7 @@ export default function WorkflowsPage() {
   const workflows: Workflow[] = data?.workflows || []
 
   return (
-    <div className="p-6">
+    <div className="h-full overflow-auto p-6">
       <div className="flex items-center justify-between mb-6">
         <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Workflows</h1>
         <button

+ 1 - 0
webui/tsconfig.json

@@ -5,6 +5,7 @@
     "lib": ["ES2020", "DOM", "DOM.Iterable"],
     "module": "ESNext",
     "skipLibCheck": true,
+    "types": ["vite/client"],
     "moduleResolution": "bundler",
     "allowImportingTsExtensions": true,
     "resolveJsonModule": true,

+ 1 - 1
webui/vite.config.ts

@@ -17,7 +17,7 @@ export default defineConfig({
         changeOrigin: true,
       },
       '/ws': {
-        target: 'ws://localhost:8090',
+        target: 'ws://localhost:8091',
         ws: true,
       },
     },