@@ -41,6 +41,7 @@ import {
4141} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks'
4242import { ROW_STYLES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
4343import {
44+ collectExpandableNodeIds ,
4445 type EntryNode ,
4546 type ExecutionGroup ,
4647 flattenBlockEntriesOnly ,
@@ -67,6 +68,21 @@ const MIN_HEIGHT = TERMINAL_HEIGHT.MIN
6768const DEFAULT_EXPANDED_HEIGHT = TERMINAL_HEIGHT . DEFAULT
6869const MIN_OUTPUT_PANEL_WIDTH_PX = OUTPUT_PANEL_WIDTH . MIN
6970
71+ /** Returns true if any node in the subtree has an error */
72+ function hasErrorInTree ( nodes : EntryNode [ ] ) : boolean {
73+ return nodes . some ( ( n ) => Boolean ( n . entry . error ) || hasErrorInTree ( n . children ) )
74+ }
75+
76+ /** Returns true if any node in the subtree is currently running */
77+ function hasRunningInTree ( nodes : EntryNode [ ] ) : boolean {
78+ return nodes . some ( ( n ) => Boolean ( n . entry . isRunning ) || hasRunningInTree ( n . children ) )
79+ }
80+
81+ /** Returns true if any node in the subtree was canceled */
82+ function hasCanceledInTree ( nodes : EntryNode [ ] ) : boolean {
83+ return nodes . some ( ( n ) => Boolean ( n . entry . isCanceled ) || hasCanceledInTree ( n . children ) )
84+ }
85+
7086/**
7187 * Block row component for displaying actual block entries
7288 */
@@ -338,6 +354,122 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
338354 )
339355} )
340356
357+ /**
358+ * Workflow node component - shows workflow block header with nested child blocks
359+ */
360+ const WorkflowNodeRow = memo ( function WorkflowNodeRow ( {
361+ node,
362+ selectedEntryId,
363+ onSelectEntry,
364+ expandedNodes,
365+ onToggleNode,
366+ } : {
367+ node : EntryNode
368+ selectedEntryId : string | null
369+ onSelectEntry : ( entry : ConsoleEntry ) => void
370+ expandedNodes : Set < string >
371+ onToggleNode : ( nodeId : string ) => void
372+ } ) {
373+ const { entry, children } = node
374+ const BlockIcon = getBlockIcon ( entry . blockType )
375+ const bgColor = getBlockColor ( entry . blockType )
376+ const nodeId = entry . id
377+ const isExpanded = expandedNodes . has ( nodeId )
378+ const hasChildren = children . length > 0
379+ const isSelected = selectedEntryId === entry . id
380+
381+ const hasError = useMemo (
382+ ( ) => Boolean ( entry . error ) || hasErrorInTree ( children ) ,
383+ [ entry . error , children ]
384+ )
385+ const hasRunningDescendant = useMemo (
386+ ( ) => Boolean ( entry . isRunning ) || hasRunningInTree ( children ) ,
387+ [ entry . isRunning , children ]
388+ )
389+ const hasCanceledDescendant = useMemo (
390+ ( ) => ( Boolean ( entry . isCanceled ) || hasCanceledInTree ( children ) ) && ! hasRunningDescendant ,
391+ [ entry . isCanceled , children , hasRunningDescendant ]
392+ )
393+
394+ return (
395+ < div className = 'flex min-w-0 flex-col' >
396+ { /* Workflow Block Header */ }
397+ < div
398+ className = { clsx (
399+ ROW_STYLES . base ,
400+ 'h-[26px]' ,
401+ isSelected ? ROW_STYLES . selected : ROW_STYLES . hover
402+ ) }
403+ onClick = { ( e ) => {
404+ e . stopPropagation ( )
405+ if ( ! isSelected ) onSelectEntry ( entry )
406+ if ( hasChildren ) onToggleNode ( nodeId )
407+ } }
408+ >
409+ < div className = 'flex min-w-0 flex-1 items-center gap-[8px]' >
410+ < div
411+ className = 'flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
412+ style = { { background : bgColor } }
413+ >
414+ { BlockIcon && < BlockIcon className = 'h-[9px] w-[9px] text-white' /> }
415+ </ div >
416+ < span
417+ className = { clsx (
418+ 'min-w-0 truncate font-medium text-[13px]' ,
419+ hasError
420+ ? 'text-[var(--text-error)]'
421+ : isSelected || isExpanded
422+ ? 'text-[var(--text-primary)]'
423+ : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
424+ ) }
425+ >
426+ { entry . blockName }
427+ </ span >
428+ { hasChildren && (
429+ < ChevronDown
430+ className = { clsx (
431+ 'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]' ,
432+ ! isExpanded && '-rotate-90'
433+ ) }
434+ />
435+ ) }
436+ </ div >
437+ < span
438+ className = { clsx (
439+ 'flex-shrink-0 font-medium text-[13px]' ,
440+ ! hasRunningDescendant &&
441+ ( hasCanceledDescendant
442+ ? 'text-[var(--text-secondary)]'
443+ : 'text-[var(--text-tertiary)]' )
444+ ) }
445+ >
446+ < StatusDisplay
447+ isRunning = { hasRunningDescendant }
448+ isCanceled = { hasCanceledDescendant }
449+ formattedDuration = { formatDuration ( entry . durationMs , { precision : 2 } ) ?? '-' }
450+ />
451+ </ span >
452+ </ div >
453+
454+ { /* Nested Child Blocks — rendered through EntryNodeRow for full loop/parallel support */ }
455+ { isExpanded && hasChildren && (
456+ < div className = { ROW_STYLES . nested } >
457+ { children . map ( ( child ) => (
458+ < EntryNodeRow
459+ key = { child . entry . id }
460+ node = { child }
461+ selectedEntryId = { selectedEntryId }
462+ onSelectEntry = { onSelectEntry }
463+ expandedNodes = { expandedNodes }
464+ onToggleNode = { onToggleNode }
465+ />
466+ ) ) }
467+ </ div >
468+ ) }
469+ </ div >
470+ )
471+ } )
472+
341473/**
342474 * Entry node component - dispatches to appropriate component based on node type
343475 */
@@ -368,6 +500,18 @@ const EntryNodeRow = memo(function EntryNodeRow({
368500 )
369501 }
370502
503+ if ( nodeType === 'workflow' ) {
504+ return (
505+ < WorkflowNodeRow
506+ node = { node }
507+ selectedEntryId = { selectedEntryId }
508+ onSelectEntry = { onSelectEntry }
509+ expandedNodes = { expandedNodes }
510+ onToggleNode = { onToggleNode }
511+ />
512+ )
513+ }
514+
371515 if ( nodeType === 'iteration' ) {
372516 return (
373517 < IterationNodeRow
@@ -659,27 +803,15 @@ export const Terminal = memo(function Terminal() {
659803 ] )
660804
661805 /**
662- * Auto-expand subflows and iterations when new entries arrive.
806+ * Auto-expand subflows, iterations, and workflow nodes when new entries arrive.
807+ * Recursively walks the full tree so nested nodes (e.g. a workflow block inside
808+ * a loop iteration) are also expanded automatically.
663809 * This always runs regardless of autoSelectEnabled - new runs should always be visible.
664810 */
665811 useEffect ( ( ) => {
666812 if ( executionGroups . length === 0 ) return
667813
668- const newestExec = executionGroups [ 0 ]
669-
670- // Collect all node IDs that should be expanded (subflows and their iterations)
671- const nodeIdsToExpand : string [ ] = [ ]
672- for ( const node of newestExec . entryTree ) {
673- if ( node . nodeType === 'subflow' && node . children . length > 0 ) {
674- nodeIdsToExpand . push ( node . entry . id )
675- // Also expand all iteration children
676- for ( const iterNode of node . children ) {
677- if ( iterNode . nodeType === 'iteration' ) {
678- nodeIdsToExpand . push ( iterNode . entry . id )
679- }
680- }
681- }
682- }
814+ const nodeIdsToExpand = collectExpandableNodeIds ( executionGroups [ 0 ] . entryTree )
683815
684816 if ( nodeIdsToExpand . length > 0 ) {
685817 setExpandedNodes ( ( prev ) => {
0 commit comments