Jelajahi Sumber

feat: group a loop's results by iteration in the execution panel

A loop body's results used to render as a flat list of every node from
every pass, in whatever order the array happened to be in - eleven
iterations of a five-node body was 55 hard-to-scan rows, and array
order wasn't even reliably chronological. Sort the flat fallback list
by startedAt (matching the same fix on the load side), and, once an
execution's records carry loopNodeId/loopIteration, collapse each
loop pass into a single card: node count, a completed/failed/skipped/
disabled breakdown, and a duration, expandable to the nodes that ran
in it.

A pass with a failure opens by default so it's visible without
expanding every iteration to find it; everything else stays collapsed.
Toggling a card flips its own default rather than tracking a fixed
initial state, so there's no need to seed expand state for every
group up front.

Only applies once every record in a loop pass has the new fields - an
execution from before the engine sent them has neither, so it falls
straight through to the same flat list this panel always rendered,
unchanged.

Also add a completed/failed/skipped/disabled breakdown to the panel
header (built from the raw records, when available) so a disabled
node's exclusion from "completed" is visible at a glance, not just
implied by its icon.
fszontagh 1 bulan lalu
induk
melakukan
b7edfeda77
1 mengubah file dengan 478 tambahan dan 251 penghapusan
  1. 478 251
      webui/src/components/workflow/ExecutionResultsPanel.tsx

+ 478 - 251
webui/src/components/workflow/ExecutionResultsPanel.tsx

@@ -1,8 +1,9 @@
-import { useMemo } from 'react'
+import { useEffect, useMemo, useState } from 'react'
 import { Node, Edge } from 'reactflow'
-import { X, ChevronRight, ChevronDown, Loader2, CheckCircle, XCircle, Repeat } from 'lucide-react'
+import { X, ChevronRight, ChevronDown, Loader2, CheckCircle, XCircle, SkipForward, Ban, Repeat } from 'lucide-react'
 import { NodeExecutionState } from './WorkflowNode'
 import { NodeOutputView } from './NodeOutputView'
+import type { NodeExecution } from '../../api/workflows'
 
 interface ExecutionState {
   executionId: string | null
@@ -10,6 +11,74 @@ interface ExecutionState {
   nodeStates: Record<string, NodeExecutionState>
   startedAt?: number
   completedAt?: number
+  // Time-sorted node execution records for a pinned/viewed execution. Only
+  // present there - see WorkflowEditorPage's effectiveExecutionState. Used
+  // here to group loop bodies by iteration once the engine has sent
+  // loopNodeId/loopIteration; absent (or missing those fields on every
+  // record) means "fall back to the flat per-node list", which is exactly
+  // how this panel behaved before grouping existed.
+  rawNodeExecutions?: NodeExecution[]
+}
+
+// One pass of a loop body: every node execution that shares a loopNodeId and
+// loopIteration, in the order they actually ran.
+interface LoopIterationGroup {
+  key: string
+  loopNodeId: string
+  iteration: number
+  entries: NodeExecution[]
+  startedAt: number
+  completedAt: number
+  hasFailure: boolean
+  counts: { completed: number; failed: number; skipped: number; disabled: number }
+}
+
+type TimelineItem =
+  | { kind: 'node'; startedAt: number; nodeId: string }
+  | { kind: 'group'; startedAt: number; group: LoopIterationGroup }
+
+// A record's status as the engine wrote it, narrowed to the four buckets the
+// UI distinguishes. Anything else (e.g. 'running', mid-execution) reads as
+// completed here rather than vanishing from a count.
+function bucketStatus(status: string): 'completed' | 'failed' | 'skipped' | 'disabled' {
+  if (status === 'failed') return 'failed'
+  if (status === 'skipped') return 'skipped'
+  if (status === 'disabled') return 'disabled'
+  return 'completed'
+}
+
+function StatusIcon({ status }: { status: string }) {
+  switch (status) {
+    case 'running':
+      return <Loader2 className="w-4 h-4 text-blue-500 animate-spin shrink-0" />
+    case 'completed':
+      return <CheckCircle className="w-4 h-4 text-green-500 shrink-0" />
+    case 'failed':
+      return <XCircle className="w-4 h-4 text-red-500 shrink-0" />
+    case 'skipped':
+      return (
+        <span title="Skipped: on a branch not taken" className="shrink-0">
+          <SkipForward className="w-4 h-4 text-gray-400 dark:text-gray-500" />
+        </span>
+      )
+    case 'disabled':
+      return (
+        <span title="Disabled: switched off by the author" className="shrink-0">
+          <Ban className="w-4 h-4 text-gray-400 dark:text-gray-500" />
+        </span>
+      )
+    default:
+      return null
+  }
+}
+
+function countsLabel(counts: LoopIterationGroup['counts']): string {
+  const parts: string[] = []
+  if (counts.completed) parts.push(`${counts.completed} completed`)
+  if (counts.failed) parts.push(`${counts.failed} failed`)
+  if (counts.skipped) parts.push(`${counts.skipped} skipped`)
+  if (counts.disabled) parts.push(`${counts.disabled} disabled`)
+  return parts.join(', ')
 }
 
 interface ExecutionResultsPanelProps {
@@ -38,6 +107,25 @@ export function ExecutionResultsPanel({
   onToggleNodeExpansion,
   onSelectIteration,
 }: ExecutionResultsPanelProps) {
+  // Which collapsed-by-default groups the user has manually opened, and
+  // which expanded-by-default (failed) groups they've manually closed. A
+  // key's presence here means "flip the default for this group". Cleared
+  // whenever the viewed execution changes, so opening one run doesn't leak
+  // its expand state into the next.
+  const [toggledGroups, setToggledGroups] = useState<Set<string>>(new Set())
+  useEffect(() => {
+    setToggledGroups(new Set())
+  }, [executionState.executionId])
+
+  const toggleGroup = (key: string) => {
+    setToggledGroups((prev) => {
+      const next = new Set(prev)
+      if (next.has(key)) next.delete(key)
+      else next.add(key)
+      return next
+    })
+  }
+
   // Identify loop nodes and their body nodes
   const { nodeToLoopMap, loopIterationCounts } = useMemo(() => {
     const loopBodyMap = new Map<string, Set<string>>() // loopId -> body node IDs (used internally)
@@ -114,82 +202,383 @@ export function ExecutionResultsPanel({
     return { nodeToLoopMap, loopIterationCounts }
   }, [nodes, edges, executionState.nodeStates])
 
-  // Sort nodes by topological order (based on workflow connections)
-  // Nodes with execution state come first, sorted by their position in the graph
+  // Nodes with execution state, sorted by when they actually started. Array
+  // order from the API is not guaranteed to be chronological (and, before
+  // the sort was added on the load side too, wasn't always) - startedAt is
+  // the one thing that reliably reflects what happened.
   const sortedNodes = useMemo(() => {
     const executedNodes = nodes.filter((node) => executionState.nodeStates[node.id])
-
-    if (executedNodes.length === 0) return []
-
-    // Build adjacency list for topological sort
-    const adjacencyList: Record<string, string[]> = {}
-    const inDegree: Record<string, number> = {}
-
-    // Initialize
-    for (const node of executedNodes) {
-      adjacencyList[node.id] = []
-      inDegree[node.id] = 0
-    }
-
-    // Build graph from edges (only for executed nodes)
-    const executedNodeIds = new Set(executedNodes.map(n => n.id))
-    for (const edge of edges) {
-      if (executedNodeIds.has(edge.source) && executedNodeIds.has(edge.target)) {
-        adjacencyList[edge.source].push(edge.target)
-        inDegree[edge.target] = (inDegree[edge.target] || 0) + 1
+    return [...executedNodes].sort((a, b) => {
+      const timeA = executionState.nodeStates[a.id]?.startedAt ?? Infinity
+      const timeB = executionState.nodeStates[b.id]?.startedAt ?? Infinity
+      return timeA - timeB
+    })
+  }, [nodes, executionState.nodeStates])
+
+  // True grouped-by-iteration view, built from the raw time-sorted records
+  // of a pinned/viewed execution. Only available once every record in a
+  // given loop pass carries loopNodeId/loopIteration - an execution from
+  // before the engine sent those fields has neither, and falls straight
+  // through to the flat sortedNodes list below, unchanged from how it
+  // rendered previously.
+  const timeline = useMemo((): TimelineItem[] | null => {
+    const raw = executionState.rawNodeExecutions
+    if (!raw || raw.length === 0) return null
+    const hasLoopFields = raw.some(e => e.loopNodeId !== undefined && e.loopIteration !== undefined)
+    if (!hasLoopFields) return null
+
+    const groupsMap = new Map<string, LoopIterationGroup>()
+    const standaloneNodeIds = new Set<string>()
+    const items: TimelineItem[] = []
+
+    for (const exec of raw) {
+      if (exec.loopNodeId !== undefined && exec.loopIteration !== undefined) {
+        const key = `${exec.loopNodeId}#${exec.loopIteration}`
+        let group = groupsMap.get(key)
+        if (!group) {
+          group = {
+            key,
+            loopNodeId: exec.loopNodeId,
+            iteration: exec.loopIteration,
+            entries: [],
+            startedAt: exec.startedAt ?? 0,
+            completedAt: exec.finishedAt ?? 0,
+            hasFailure: false,
+            counts: { completed: 0, failed: 0, skipped: 0, disabled: 0 },
+          }
+          groupsMap.set(key, group)
+          items.push({ kind: 'group', startedAt: group.startedAt, group })
+        }
+        group.entries.push(exec)
+        if (exec.startedAt !== undefined) group.startedAt = Math.min(group.startedAt, exec.startedAt)
+        if (exec.finishedAt !== undefined) group.completedAt = Math.max(group.completedAt, exec.finishedAt)
+        const bucket = bucketStatus(exec.status)
+        group.counts[bucket]++
+        if (bucket === 'failed') group.hasFailure = true
+      } else if (!standaloneNodeIds.has(exec.nodeId)) {
+        // A node outside any loop appears once - use its recorded startedAt
+        // (falling back to the aggregated nodeStates entry) for ordering.
+        standaloneNodeIds.add(exec.nodeId)
+        items.push({
+          kind: 'node',
+          startedAt: exec.startedAt ?? executionState.nodeStates[exec.nodeId]?.startedAt ?? 0,
+          nodeId: exec.nodeId,
+        })
       }
     }
 
-    // Kahn's algorithm for topological sort
-    const queue: string[] = []
-    const result: string[] = []
+    groupsMap.forEach(group => {
+      group.entries.sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0))
+      // Group start reflects the item's own position in the timeline, but
+      // items were pushed to `items` when the group was first seen, which
+      // already coincides with its earliest entry - the min() above just
+      // keeps `group.startedAt` itself accurate for the duration readout.
+    })
 
-    // Start with nodes that have no incoming edges (triggers/start nodes)
-    for (const nodeId of Object.keys(inDegree)) {
-      if (inDegree[nodeId] === 0) {
-        queue.push(nodeId)
+    items.sort((a, b) => a.startedAt - b.startedAt)
+    return items
+  }, [executionState.rawNodeExecutions, executionState.nodeStates])
+
+  // Overall status breakdown for the header. Only computed from raw records
+  // (accurate per-iteration counts); when they aren't available - a live
+  // run, or an execution predating this feature - the panel simply doesn't
+  // show a breakdown rather than risk a wrong one.
+  const statusCounts = useMemo(() => {
+    const raw = executionState.rawNodeExecutions
+    if (!raw || raw.length === 0) return null
+    const counts = { completed: 0, failed: 0, skipped: 0, disabled: 0 }
+    for (const exec of raw) counts[bucketStatus(exec.status)]++
+    return counts
+  }, [executionState.rawNodeExecutions])
+
+  // Shared row renderer for a single node's result, used both by the flat
+  // fallback list and by standalone (non-loop) entries in the grouped
+  // timeline. When `suppressIterationSelector` is set (the grouped timeline
+  // is active), a loop node's own row skips the old per-node iteration
+  // stepper - the iteration groups below it are now how you browse passes.
+  function renderNodeRow(node: Node, suppressIterationSelector: boolean) {
+    const nodeState = executionState.nodeStates[node.id]
+    if (!nodeState) return null
+    const isExpanded = expandedNodes.has(node.id)
+    const isLoopNode = node.data.type === 'loop'
+    const loopIterCount = loopIterationCounts.get(node.id) || 0
+    const parentLoopId = nodeToLoopMap.get(node.id)
+    const isLoopBodyNode = !!parentLoopId
+
+    const getSelectedIteration = () => {
+      if (isLoopBodyNode && parentLoopId) {
+        return selectedIterations[parentLoopId] ?? 0
       }
+      return selectedIterations[node.id] ?? 0
     }
 
-    while (queue.length > 0) {
-      // Sort queue by startedAt timestamp to break ties (nodes at same level)
-      queue.sort((a, b) => {
-        const timeA = executionState.nodeStates[a]?.startedAt ?? Infinity
-        const timeB = executionState.nodeStates[b]?.startedAt ?? Infinity
-        return timeA - timeB
-      })
+    return (
+      <div key={node.id} className="p-3">
+        <button
+          onClick={() => onToggleNodeExpansion(node.id)}
+          className="w-full flex items-center gap-2 text-left"
+        >
+          {isExpanded ? (
+            <ChevronDown className="w-4 h-4 text-gray-400 dark:text-gray-500" />
+          ) : (
+            <ChevronRight className="w-4 h-4 text-gray-400 dark:text-gray-500" />
+          )}
 
-      const nodeId = queue.shift()!
-      result.push(nodeId)
+          <StatusIcon status={nodeState.status} />
 
-      for (const neighbor of adjacencyList[nodeId] || []) {
-        inDegree[neighbor]--
-        if (inDegree[neighbor] === 0) {
-          queue.push(neighbor)
-        }
-      }
-    }
+          <div className="flex-1 min-w-0">
+            <span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate block">
+              {node.data.config?._customLabel || node.data.label}
+            </span>
+            {node.data.config?._customLabel && (
+              <span className="text-xs text-gray-500 dark:text-gray-400 truncate block">
+                {node.data.label} ({node.data.type})
+              </span>
+            )}
+          </div>
 
-    // Map back to node objects
-    const nodeMap = new Map(executedNodes.map(n => [n.id, n]))
-    const sortedResult = result.map(id => nodeMap.get(id)!).filter(Boolean)
-
-    // If topological sort didn't include all nodes (cycles or disconnected),
-    // fall back to timestamp order for remaining nodes
-    if (sortedResult.length < executedNodes.length) {
-      const included = new Set(result)
-      const remaining = executedNodes
-        .filter(n => !included.has(n.id))
-        .sort((a, b) => {
-          const timeA = executionState.nodeStates[a.id]?.startedAt ?? Infinity
-          const timeB = executionState.nodeStates[b.id]?.startedAt ?? Infinity
-          return timeA - timeB
-        })
-      return [...sortedResult, ...remaining]
-    }
+          {/* Iteration count badge for loop nodes */}
+          {isLoopNode && loopIterCount > 0 && (
+            <span className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs font-medium flex items-center gap-1">
+              <Repeat className="w-3 h-3" />
+              {loopIterCount}
+            </span>
+          )}
 
-    return sortedResult
-  }, [nodes, edges, executionState.nodeStates])
+          {/* Show iteration indicator for body nodes (flat fallback only) */}
+          {isLoopBodyNode && nodeState.iterations && nodeState.iterations.length > 0 && (
+            <span className="px-1.5 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400 rounded text-xs">
+              #{getSelectedIteration() + 1}
+            </span>
+          )}
+
+          {nodeState.startedAt && nodeState.completedAt && (
+            <span className="text-xs text-gray-400 dark:text-gray-500">
+              {((nodeState.completedAt - nodeState.startedAt) / 1000).toFixed(2)}s
+            </span>
+          )}
+        </button>
+
+        {isExpanded && (
+          <div className="mt-2 ml-6">
+            {/* Iteration selector on LOOP nodes only, and only in the flat
+                fallback - the grouped timeline replaces this with real
+                per-iteration cards further down the list. */}
+            {isLoopNode && loopIterCount > 0 && !suppressIterationSelector && (
+              <div className="mb-3 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded">
+                <div className="flex items-center gap-2">
+                  <span className="text-xs text-blue-700 dark:text-blue-300 font-medium">View iteration:</span>
+                  <div className="flex items-center gap-1">
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        const current = selectedIterations[node.id] ?? 0
+                        if (current > 0) {
+                          onSelectIteration(node.id, current - 1)
+                        }
+                      }}
+                      disabled={(selectedIterations[node.id] ?? 0) === 0}
+                      className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-200 rounded hover:bg-blue-200 dark:hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
+                    >
+                      ←
+                    </button>
+                    <select
+                      value={selectedIterations[node.id] ?? 0}
+                      onChange={(e) => {
+                        e.stopPropagation()
+                        onSelectIteration(node.id, parseInt(e.target.value))
+                      }}
+                      onClick={(e) => e.stopPropagation()}
+                      className="px-2 py-0.5 text-xs border border-blue-300 dark:border-blue-700 rounded bg-white dark:bg-slate-800 text-blue-900 dark:text-blue-100"
+                    >
+                      {Array.from({ length: loopIterCount }, (_, idx) => (
+                        <option key={idx} value={idx}>
+                          {idx + 1} / {loopIterCount}
+                        </option>
+                      ))}
+                    </select>
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        const current = selectedIterations[node.id] ?? 0
+                        if (current < loopIterCount - 1) {
+                          onSelectIteration(node.id, current + 1)
+                        }
+                      }}
+                      disabled={(selectedIterations[node.id] ?? 0) >= loopIterCount - 1}
+                      className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-200 rounded hover:bg-blue-200 dark:hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
+                    >
+                      →
+                    </button>
+                  </div>
+                </div>
+                <div className="text-xs text-blue-600 dark:text-blue-400 mt-1">
+                  Changing iteration updates all nodes in this loop
+                </div>
+              </div>
+            )}
+
+            {/* Body node iteration output - use parent loop's selected iteration (flat fallback only) */}
+            {isLoopBodyNode && nodeState.iterations && nodeState.iterations.length > 0 ? (
+              <div className="space-y-2">
+                {(() => {
+                  const selectedIdx = getSelectedIteration()
+                  const selectedIter = nodeState.iterations[selectedIdx]
+                  if (!selectedIter) return <div className="text-xs text-gray-400 dark:text-gray-500 italic">No data for this iteration</div>
+
+                  return (
+                    <div>
+                      {selectedIter.status === 'disabled' ? (
+                        <div className="text-xs text-gray-400 dark:text-gray-500 italic">Disabled: this node was switched off</div>
+                      ) : selectedIter.error ? (
+                        <div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400 font-mono">
+                          {selectedIter.error}
+                        </div>
+                      ) : selectedIter.output !== undefined ? (
+                        <NodeOutputView value={selectedIter.output} />
+                      ) : selectedIter.status === 'skipped' ? (
+                        <div className="text-xs text-gray-400 dark:text-gray-500 italic">Skipped (condition not met)</div>
+                      ) : (
+                        <div className="text-xs text-gray-400 dark:text-gray-500 italic">No output</div>
+                      )}
+                    </div>
+                  )
+                })()}
+              </div>
+            ) : isLoopNode && loopIterCount > 0 && nodeState.output?.items ? (
+              /* Loop node with items - show selected item */
+              <div className="space-y-2">
+                {(() => {
+                  const selectedIdx = selectedIterations[node.id] ?? 0
+                  const selectedItem = nodeState.output.items[selectedIdx]
+                  if (!selectedItem) return <div className="text-xs text-gray-400 dark:text-gray-500 italic">No data for this iteration</div>
+
+                  return (
+                    <NodeOutputView value={selectedItem} />
+                  )
+                })()}
+              </div>
+            ) : nodeState.status === 'disabled' ? (
+              <div className="text-xs text-gray-400 dark:text-gray-500 italic">Disabled: this node was switched off and did not run</div>
+            ) : nodeState.error ? (
+              <div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400 font-mono">
+                {nodeState.error}
+              </div>
+            ) : nodeState.output !== undefined ? (
+              <NodeOutputView value={nodeState.output} />
+            ) : (
+              <div className="text-xs text-gray-400 dark:text-gray-500 italic">No output</div>
+            )}
+          </div>
+        )}
+      </div>
+    )
+  }
+
+  // A single node's result row inside an expanded iteration group. Simpler
+  // than renderNodeRow: no loop-of-its-own concerns, since a node that owns
+  // a loop cannot itself be a member of another iteration in this data.
+  function renderGroupEntry(exec: NodeExecution, group: LoopIterationGroup, idx: number) {
+    const node = nodes.find(n => n.id === exec.nodeId)
+    const label = node?.data.config?._customLabel || node?.data.label || exec.nodeId
+    const rowKey = `iter:${group.key}:${exec.nodeId}:${idx}`
+    const isExpanded = expandedNodes.has(rowKey)
+    const status = bucketStatus(exec.status)
+    const hasBody = status !== 'disabled' && (exec.error || exec.output !== undefined)
+
+    return (
+      <div key={rowKey} className="py-1.5">
+        <button
+          onClick={() => hasBody && onToggleNodeExpansion(rowKey)}
+          className={`w-full flex items-center gap-2 text-left ${hasBody ? '' : 'cursor-default'}`}
+        >
+          {hasBody ? (
+            isExpanded ? (
+              <ChevronDown className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 shrink-0" />
+            ) : (
+              <ChevronRight className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 shrink-0" />
+            )
+          ) : (
+            <span className="w-3.5 h-3.5 shrink-0" />
+          )}
+          <StatusIcon status={exec.status} />
+          <span className="flex-1 min-w-0 text-xs text-gray-800 dark:text-gray-200 truncate">
+            {label}
+          </span>
+          {exec.startedAt !== undefined && exec.finishedAt !== undefined && (
+            <span className="text-[10px] text-gray-400 dark:text-gray-500">
+              {((exec.finishedAt - exec.startedAt) / 1000).toFixed(2)}s
+            </span>
+          )}
+        </button>
+        {hasBody && isExpanded && (
+          <div className="mt-1 ml-5">
+            {exec.error ? (
+              <div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400 font-mono">
+                {exec.error}
+              </div>
+            ) : (
+              <NodeOutputView value={exec.output} />
+            )}
+          </div>
+        )}
+      </div>
+    )
+  }
+
+  function renderGroup(group: LoopIterationGroup) {
+    // Failed iterations open by default, so a bad pass is visible without
+    // hunting through however many succeeded around it. Toggling flips that
+    // default per group.
+    const isExpanded = toggledGroups.has(group.key) ? !group.hasFailure : group.hasFailure
+    const loopNode = nodes.find(n => n.id === group.loopNodeId)
+    const loopLabel = loopNode?.data.config?._customLabel || loopNode?.data.label || group.loopNodeId
+
+    return (
+      <div key={group.key} className={`p-3 ${group.hasFailure ? 'bg-red-50/50 dark:bg-red-900/10' : ''}`}>
+        <button
+          onClick={() => toggleGroup(group.key)}
+          className="w-full flex items-center gap-2 text-left"
+        >
+          {isExpanded ? (
+            <ChevronDown className="w-4 h-4 text-gray-400 dark:text-gray-500" />
+          ) : (
+            <ChevronRight className="w-4 h-4 text-gray-400 dark:text-gray-500" />
+          )}
+          <Repeat className="w-4 h-4 text-blue-500 shrink-0" />
+          <div className="flex-1 min-w-0">
+            <span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate block">
+              {loopLabel} - iteration {group.iteration + 1}
+            </span>
+            <span className="text-xs text-gray-500 dark:text-gray-400 truncate block">
+              {group.entries.length} node{group.entries.length === 1 ? '' : 's'} - {countsLabel(group.counts)}
+            </span>
+          </div>
+          {group.hasFailure && (
+            <span className="px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded text-xs font-medium flex items-center gap-1 shrink-0">
+              <XCircle className="w-3 h-3" />
+              failed
+            </span>
+          )}
+          {group.startedAt && group.completedAt ? (
+            <span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
+              {((group.completedAt - group.startedAt) / 1000).toFixed(2)}s
+            </span>
+          ) : null}
+        </button>
+
+        {isExpanded && (
+          <div className="mt-2 ml-6 divide-y divide-gray-100 dark:divide-slate-700">
+            {group.entries.map((exec, idx) => renderGroupEntry(exec, group, idx))}
+          </div>
+        )}
+      </div>
+    )
+  }
+
+  const hasAnyResults = timeline
+    ? timeline.length > 0
+    : Object.keys(executionState.nodeStates).length > 0
 
   return (
     <div
@@ -232,199 +621,37 @@ export function ExecutionResultsPanel({
             Duration: {((executionState.completedAt - executionState.startedAt) / 1000).toFixed(2)}s
           </div>
         )}
+        {/* Ran / failed / skipped / disabled, kept as separate buckets on
+            purpose - a disabled node never ran, so it never counts toward
+            "completed", the same way a skipped branch doesn't either. */}
+        {statusCounts && (
+          <div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
+            {countsLabel(statusCounts)}
+          </div>
+        )}
       </div>
 
-      {/* Node results - sorted by topological order (workflow connections) */}
+      {/* Node results, in the order they actually ran. Loop bodies collapse
+          into one card per iteration when the execution has loopNodeId /
+          loopIteration on its records; otherwise this is the same flat,
+          startedAt-ordered list as before. */}
       <div
         className="flex-1 overflow-y-auto divide-y divide-gray-200 dark:divide-slate-700"
         style={{ overscrollBehavior: 'contain' }}
         onWheel={(e) => e.stopPropagation()}
       >
-        {sortedNodes.map((node) => {
-          const nodeState = executionState.nodeStates[node.id]
-          const isExpanded = expandedNodes.has(node.id)
-          const isLoopNode = node.data.type === 'loop'
-          const loopIterCount = loopIterationCounts.get(node.id) || 0
-          const parentLoopId = nodeToLoopMap.get(node.id)
-          const isLoopBodyNode = !!parentLoopId
-
-          // Get the selected iteration index - for body nodes, use parent loop's selection
-          const getSelectedIteration = () => {
-            if (isLoopBodyNode && parentLoopId) {
-              return selectedIterations[parentLoopId] ?? 0
-            }
-            return selectedIterations[node.id] ?? 0
-          }
-
-          return (
-            <div key={node.id} className="p-3">
-              <button
-                onClick={() => onToggleNodeExpansion(node.id)}
-                className="w-full flex items-center gap-2 text-left"
-              >
-                {isExpanded ? (
-                  <ChevronDown className="w-4 h-4 text-gray-400 dark:text-gray-500" />
-                ) : (
-                  <ChevronRight className="w-4 h-4 text-gray-400 dark:text-gray-500" />
-                )}
-
-                {nodeState.status === 'running' && (
-                  <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
-                )}
-                {nodeState.status === 'completed' && (
-                  <CheckCircle className="w-4 h-4 text-green-500" />
-                )}
-                {nodeState.status === 'failed' && (
-                  <XCircle className="w-4 h-4 text-red-500" />
-                )}
-                {nodeState.status === 'skipped' && (
-                  <span className="w-4 h-4 text-gray-400 dark:text-gray-500 flex items-center justify-center text-xs">⊘</span>
-                )}
-
-                <div className="flex-1 min-w-0">
-                  <span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate block">
-                    {node.data.config?._customLabel || node.data.label}
-                  </span>
-                  {node.data.config?._customLabel && (
-                    <span className="text-xs text-gray-500 dark:text-gray-400 truncate block">
-                      {node.data.label} ({node.data.type})
-                    </span>
-                  )}
-                </div>
-
-                {/* Iteration count badge for loop nodes */}
-                {isLoopNode && loopIterCount > 0 && (
-                  <span className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs font-medium flex items-center gap-1">
-                    <Repeat className="w-3 h-3" />
-                    {loopIterCount}
-                  </span>
-                )}
-
-                {/* Show iteration indicator for body nodes */}
-                {isLoopBodyNode && nodeState.iterations && nodeState.iterations.length > 0 && (
-                  <span className="px-1.5 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400 rounded text-xs">
-                    #{getSelectedIteration() + 1}
-                  </span>
-                )}
-
-                {nodeState.startedAt && nodeState.completedAt && (
-                  <span className="text-xs text-gray-400 dark:text-gray-500">
-                    {((nodeState.completedAt - nodeState.startedAt) / 1000).toFixed(2)}s
-                  </span>
-                )}
-              </button>
-
-              {isExpanded && (
-                <div className="mt-2 ml-6">
-                  {/* Iteration selector on LOOP nodes only */}
-                  {isLoopNode && loopIterCount > 0 && (
-                    <div className="mb-3 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded">
-                      <div className="flex items-center gap-2">
-                        <span className="text-xs text-blue-700 dark:text-blue-300 font-medium">View iteration:</span>
-                        <div className="flex items-center gap-1">
-                          <button
-                            onClick={(e) => {
-                              e.stopPropagation()
-                              const current = selectedIterations[node.id] ?? 0
-                              if (current > 0) {
-                                onSelectIteration(node.id, current - 1)
-                              }
-                            }}
-                            disabled={(selectedIterations[node.id] ?? 0) === 0}
-                            className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-200 rounded hover:bg-blue-200 dark:hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
-                          >
-                            ←
-                          </button>
-                          <select
-                            value={selectedIterations[node.id] ?? 0}
-                            onChange={(e) => {
-                              e.stopPropagation()
-                              onSelectIteration(node.id, parseInt(e.target.value))
-                            }}
-                            onClick={(e) => e.stopPropagation()}
-                            className="px-2 py-0.5 text-xs border border-blue-300 dark:border-blue-700 rounded bg-white dark:bg-slate-800 text-blue-900 dark:text-blue-100"
-                          >
-                            {Array.from({ length: loopIterCount }, (_, idx) => (
-                              <option key={idx} value={idx}>
-                                {idx + 1} / {loopIterCount}
-                              </option>
-                            ))}
-                          </select>
-                          <button
-                            onClick={(e) => {
-                              e.stopPropagation()
-                              const current = selectedIterations[node.id] ?? 0
-                              if (current < loopIterCount - 1) {
-                                onSelectIteration(node.id, current + 1)
-                              }
-                            }}
-                            disabled={(selectedIterations[node.id] ?? 0) >= loopIterCount - 1}
-                            className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-200 rounded hover:bg-blue-200 dark:hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
-                          >
-                            →
-                          </button>
-                        </div>
-                      </div>
-                      <div className="text-xs text-blue-600 dark:text-blue-400 mt-1">
-                        Changing iteration updates all nodes in this loop
-                      </div>
-                    </div>
-                  )}
-
-                  {/* Body node iteration output - use parent loop's selected iteration */}
-                  {isLoopBodyNode && nodeState.iterations && nodeState.iterations.length > 0 ? (
-                    <div className="space-y-2">
-                      {(() => {
-                        const selectedIdx = getSelectedIteration()
-                        const selectedIter = nodeState.iterations[selectedIdx]
-                        if (!selectedIter) return <div className="text-xs text-gray-400 dark:text-gray-500 italic">No data for this iteration</div>
-
-                        return (
-                          <div>
-                            {selectedIter.error ? (
-                              <div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400 font-mono">
-                                {selectedIter.error}
-                              </div>
-                            ) : selectedIter.output !== undefined ? (
-                              <NodeOutputView value={selectedIter.output} />
-                            ) : selectedIter.status === 'skipped' ? (
-                              <div className="text-xs text-gray-400 dark:text-gray-500 italic">Skipped (condition not met)</div>
-                            ) : (
-                              <div className="text-xs text-gray-400 dark:text-gray-500 italic">No output</div>
-                            )}
-                          </div>
-                        )
-                      })()}
-                    </div>
-                  ) : isLoopNode && loopIterCount > 0 && nodeState.output?.items ? (
-                    /* Loop node with items - show selected item */
-                    <div className="space-y-2">
-                      {(() => {
-                        const selectedIdx = selectedIterations[node.id] ?? 0
-                        const selectedItem = nodeState.output.items[selectedIdx]
-                        if (!selectedItem) return <div className="text-xs text-gray-400 dark:text-gray-500 italic">No data for this iteration</div>
-
-                        return (
-                          <NodeOutputView value={selectedItem} />
-                        )
-                      })()}
-                    </div>
-                  ) : nodeState.error ? (
-                    <div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400 font-mono">
-                      {nodeState.error}
-                    </div>
-                  ) : nodeState.output !== undefined ? (
-                    <NodeOutputView value={nodeState.output} />
-                  ) : (
-                    <div className="text-xs text-gray-400 dark:text-gray-500 italic">No output</div>
-                  )}
-                </div>
-              )}
-            </div>
-          )
-        })}
-
-        {Object.keys(executionState.nodeStates).length === 0 && (
+        {timeline
+          ? timeline.map(item =>
+              item.kind === 'group'
+                ? renderGroup(item.group)
+                : (() => {
+                    const node = nodes.find(n => n.id === item.nodeId)
+                    return node ? renderNodeRow(node, true) : null
+                  })()
+            )
+          : sortedNodes.map((node) => renderNodeRow(node, false))}
+
+        {!hasAnyResults && (
           <div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
             {executionState.status === 'running'
               ? 'Waiting for execution results...'