Skip to content

Commit 42d09ef

Browse files
waleedlatif1claude
andcommitted
feat(terminal): show child workflow blocks as nested expandable tree
When a workflow block executes a child workflow, the terminal console now shows each child block as a nested expandable entry under the parent — matching the existing loop/parallel subflow pattern. - Extract childTraceSpans from workflow block output into console entries - Extend buildEntryTree with recursive workflow node nesting - Add 'workflow' node type to EntryNodeRow with recursive rendering - Shared typed utility (extractChildWorkflowEntries) for both execution paths - Filter UI excludes synthetic child IDs; children follow parent visibility - CSV export and error notifications skip child workflow entries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cbb98a0 commit 42d09ef

File tree

10 files changed

+318
-34
lines changed

10 files changed

+318
-34
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,21 +88,38 @@ export function useTerminalFilters() {
8888
let result = entries
8989

9090
if (hasActiveFilters) {
91-
result = entries.filter((entry) => {
92-
// Block ID filter
91+
// Determine which top-level entries pass the filters
92+
const visibleBlockIds = new Set<string>()
93+
for (const entry of entries) {
94+
if (entry.parentWorkflowBlockId) continue
95+
96+
let passes = true
9397
if (filters.blockIds.size > 0 && !filters.blockIds.has(entry.blockId)) {
94-
return false
98+
passes = false
9599
}
96-
97-
// Status filter
98-
if (filters.statuses.size > 0) {
100+
if (passes && filters.statuses.size > 0) {
99101
const isError = !!entry.error
100102
const hasStatus = isError ? filters.statuses.has('error') : filters.statuses.has('info')
101-
if (!hasStatus) return false
103+
if (!hasStatus) passes = false
104+
}
105+
if (passes) {
106+
visibleBlockIds.add(entry.blockId)
107+
}
108+
}
109+
110+
// Propagate visibility to child workflow entries (handles arbitrary nesting).
111+
// Keep iterating until no new children are discovered.
112+
let prevSize = 0
113+
while (visibleBlockIds.size !== prevSize) {
114+
prevSize = visibleBlockIds.size
115+
for (const entry of entries) {
116+
if (entry.parentWorkflowBlockId && visibleBlockIds.has(entry.parentWorkflowBlockId)) {
117+
visibleBlockIds.add(entry.blockId)
118+
}
102119
}
120+
}
103121

104-
return true
105-
})
122+
result = entries.filter((entry) => visibleBlockIds.has(entry.blockId))
106123
}
107124

108125
// Sort by executionOrder (monotonically increasing integer from server)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,8 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
339339
})
340340

341341
/**
342-
* Entry node component - dispatches to appropriate component based on node type
342+
* Entry node component - dispatches to appropriate component based on node type.
343+
* Handles recursive rendering for workflow nodes with arbitrarily nested children.
343344
*/
344345
const EntryNodeRow = memo(function EntryNodeRow({
345346
node,
@@ -380,6 +381,98 @@ const EntryNodeRow = memo(function EntryNodeRow({
380381
)
381382
}
382383

384+
if (nodeType === 'workflow') {
385+
const { entry, children } = node
386+
const BlockIcon = getBlockIcon(entry.blockType)
387+
const hasError = Boolean(entry.error) || children.some((c) => c.entry.error)
388+
const bgColor = getBlockColor(entry.blockType)
389+
const nodeId = entry.id
390+
const isExpanded = expandedNodes.has(nodeId)
391+
const hasChildren = children.length > 0
392+
const isSelected = selectedEntryId === entry.id
393+
const isRunning = Boolean(entry.isRunning)
394+
const isCanceled = Boolean(entry.isCanceled)
395+
396+
return (
397+
<div className='flex min-w-0 flex-col'>
398+
{/* Workflow Block Header */}
399+
<div
400+
data-entry-id={entry.id}
401+
className={clsx(
402+
ROW_STYLES.base,
403+
'h-[26px]',
404+
isSelected ? ROW_STYLES.selected : ROW_STYLES.hover
405+
)}
406+
onClick={(e) => {
407+
e.stopPropagation()
408+
if (hasChildren) {
409+
onToggleNode(nodeId)
410+
}
411+
onSelectEntry(entry)
412+
}}
413+
>
414+
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
415+
<div
416+
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
417+
style={{ background: bgColor }}
418+
>
419+
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
420+
</div>
421+
<span
422+
className={clsx(
423+
'min-w-0 truncate font-medium text-[13px]',
424+
hasError
425+
? 'text-[var(--text-error)]'
426+
: isSelected || isExpanded
427+
? 'text-[var(--text-primary)]'
428+
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
429+
)}
430+
>
431+
{entry.blockName}
432+
</span>
433+
{hasChildren && (
434+
<ChevronDown
435+
className={clsx(
436+
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
437+
!isExpanded && '-rotate-90'
438+
)}
439+
/>
440+
)}
441+
</div>
442+
<span
443+
className={clsx(
444+
'flex-shrink-0 font-medium text-[13px]',
445+
!isRunning &&
446+
(isCanceled ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]')
447+
)}
448+
>
449+
<StatusDisplay
450+
isRunning={isRunning}
451+
isCanceled={isCanceled}
452+
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
453+
/>
454+
</span>
455+
</div>
456+
457+
{/* Nested Child Workflow Blocks (recursive) */}
458+
{isExpanded && hasChildren && (
459+
<div className={ROW_STYLES.nested}>
460+
{children.map((child) => (
461+
<EntryNodeRow
462+
key={child.entry.id}
463+
node={child}
464+
selectedEntryId={selectedEntryId}
465+
onSelectEntry={onSelectEntry}
466+
expandedNodes={expandedNodes}
467+
onToggleNode={onToggleNode}
468+
/>
469+
))}
470+
</div>
471+
)}
472+
</div>
473+
)
474+
}
475+
383476
// Regular block
384477
return (
385478
<BlockRow
@@ -555,6 +648,8 @@ export const Terminal = memo(function Terminal() {
555648
const uniqueBlocks = useMemo(() => {
556649
const blocksMap = new Map<string, { blockId: string; blockName: string; blockType: string }>()
557650
allWorkflowEntries.forEach((entry) => {
651+
// Skip child workflow entries — they use synthetic IDs and shouldn't appear in filters
652+
if (entry.parentWorkflowBlockId) return
558653
if (!blocksMap.has(entry.blockId)) {
559654
blocksMap.set(entry.blockId, {
560655
blockId: entry.blockId,
@@ -667,19 +762,22 @@ export const Terminal = memo(function Terminal() {
667762

668763
const newestExec = executionGroups[0]
669764

670-
// Collect all node IDs that should be expanded (subflows and their iterations)
765+
// Collect all expandable node IDs recursively (subflows, iterations, and workflow nodes)
671766
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-
}
767+
const collectExpandableNodes = (nodes: EntryNode[]) => {
768+
for (const node of nodes) {
769+
if (node.children.length === 0) continue
770+
if (
771+
node.nodeType === 'subflow' ||
772+
node.nodeType === 'iteration' ||
773+
node.nodeType === 'workflow'
774+
) {
775+
nodeIdsToExpand.push(node.entry.id)
776+
collectExpandableNodes(node.children)
680777
}
681778
}
682779
}
780+
collectExpandableNodes(newestExec.entryTree)
683781

684782
if (nodeIdsToExpand.length > 0) {
685783
setExpandedNodes((prev) => {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,10 @@ export function isSubflowBlockType(blockType: string): boolean {
120120
/**
121121
* Node type for the tree structure
122122
*/
123-
export type EntryNodeType = 'block' | 'subflow' | 'iteration'
123+
export type EntryNodeType = 'block' | 'subflow' | 'iteration' | 'workflow'
124124

125125
/**
126-
* Entry node for tree structure - represents a block, subflow, or iteration
126+
* Entry node for tree structure - represents a block, subflow, iteration, or workflow
127127
*/
128128
export interface EntryNode {
129129
/** The console entry (for blocks) or synthetic entry (for subflows/iterations) */
@@ -175,12 +175,17 @@ interface IterationGroup {
175175
* Sorts by start time to ensure chronological order.
176176
*/
177177
function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
178-
// Separate regular blocks from iteration entries
178+
// Separate regular blocks from iteration entries and child workflow entries
179179
const regularBlocks: ConsoleEntry[] = []
180180
const iterationEntries: ConsoleEntry[] = []
181+
const childWorkflowEntries = new Map<string, ConsoleEntry[]>()
181182

182183
for (const entry of entries) {
183-
if (entry.iterationType && entry.iterationCurrent !== undefined) {
184+
if (entry.parentWorkflowBlockId) {
185+
const existing = childWorkflowEntries.get(entry.parentWorkflowBlockId) || []
186+
existing.push(entry)
187+
childWorkflowEntries.set(entry.parentWorkflowBlockId, existing)
188+
} else if (entry.iterationType && entry.iterationCurrent !== undefined) {
184189
iterationEntries.push(entry)
185190
} else {
186191
regularBlocks.push(entry)
@@ -338,12 +343,53 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
338343
})
339344
}
340345

341-
// Build nodes for regular blocks
342-
const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({
343-
entry,
344-
children: [],
345-
nodeType: 'block' as const,
346-
}))
346+
/**
347+
* Recursively builds child nodes for workflow blocks.
348+
* Handles multi-level nesting where a child workflow block itself has children.
349+
*/
350+
const buildWorkflowChildNodes = (parentBlockId: string): EntryNode[] => {
351+
const childEntries = childWorkflowEntries.get(parentBlockId)
352+
if (!childEntries || childEntries.length === 0) return []
353+
354+
childEntries.sort((a, b) => {
355+
const aTime = new Date(a.startedAt || a.timestamp).getTime()
356+
const bTime = new Date(b.startedAt || b.timestamp).getTime()
357+
return aTime - bTime
358+
})
359+
360+
return childEntries.map((child) => {
361+
const nestedChildren = buildWorkflowChildNodes(child.blockId)
362+
if (nestedChildren.length > 0) {
363+
return {
364+
entry: child,
365+
children: nestedChildren,
366+
nodeType: 'workflow' as const,
367+
}
368+
}
369+
return {
370+
entry: child,
371+
children: [],
372+
nodeType: 'block' as const,
373+
}
374+
})
375+
}
376+
377+
// Build nodes for regular blocks, promoting workflow blocks with children to 'workflow' nodes
378+
const regularNodes: EntryNode[] = regularBlocks.map((entry) => {
379+
const childNodes = buildWorkflowChildNodes(entry.blockId)
380+
if (childNodes.length > 0) {
381+
return {
382+
entry,
383+
children: childNodes,
384+
nodeType: 'workflow' as const,
385+
}
386+
}
387+
return {
388+
entry,
389+
children: [],
390+
nodeType: 'block' as const,
391+
}
392+
})
347393

348394
// Combine all nodes and sort by executionOrder ascending (oldest first, top-down)
349395
const allNodes = [...subflowNodes, ...regularNodes]

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/executi
3838
import { useNotificationStore } from '@/stores/notifications'
3939
import { useVariablesStore } from '@/stores/panel'
4040
import { useEnvironmentStore } from '@/stores/settings/environment'
41-
import { useTerminalConsoleStore } from '@/stores/terminal'
41+
import {
42+
extractChildWorkflowEntries,
43+
hasChildTraceSpans,
44+
useTerminalConsoleStore,
45+
} from '@/stores/terminal'
4246
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
4347
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
4448
import { mergeSubblockState } from '@/stores/workflows/utils'
@@ -517,6 +521,20 @@ export function useWorkflowExecution() {
517521
addConsoleEntry(data, data.output as NormalizedBlockOutput)
518522
}
519523

524+
// Extract child workflow trace spans into separate console entries
525+
if (data.blockType === 'workflow' && hasChildTraceSpans(data.output)) {
526+
const childEntries = extractChildWorkflowEntries({
527+
parentBlockId: data.blockId,
528+
executionId: executionIdRef.current,
529+
executionOrder: data.executionOrder,
530+
workflowId: workflowId!,
531+
childTraceSpans: data.output.childTraceSpans,
532+
})
533+
for (const entry of childEntries) {
534+
addConsole(entry)
535+
}
536+
}
537+
520538
if (onBlockCompleteCallback) {
521539
onBlockCompleteCallback(data.blockId, data.output).catch((error) => {
522540
logger.error('Error in onBlockComplete callback:', error)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { v4 as uuidv4 } from 'uuid'
22
import type { ExecutionResult, StreamingExecution } from '@/executor/types'
33
import { useExecutionStore } from '@/stores/execution'
4-
import { useTerminalConsoleStore } from '@/stores/terminal'
4+
import {
5+
extractChildWorkflowEntries,
6+
hasChildTraceSpans,
7+
useTerminalConsoleStore,
8+
} from '@/stores/terminal'
59
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
610
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
711

@@ -149,6 +153,20 @@ export async function executeWorkflowWithFullLogging(
149153
iterationContainerId: event.data.iterationContainerId,
150154
})
151155

156+
// Extract child workflow trace spans into separate console entries
157+
if (event.data.blockType === 'workflow' && hasChildTraceSpans(event.data.output)) {
158+
const childEntries = extractChildWorkflowEntries({
159+
parentBlockId: event.data.blockId,
160+
executionId,
161+
executionOrder: event.data.executionOrder,
162+
workflowId: activeWorkflowId,
163+
childTraceSpans: event.data.output.childTraceSpans,
164+
})
165+
for (const entry of childEntries) {
166+
addConsole(entry)
167+
}
168+
}
169+
152170
if (options.onBlockComplete) {
153171
options.onBlockComplete(event.data.blockId, event.data.output).catch(() => {})
154172
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { indexedDBStorage } from './storage'
22
export { useTerminalConsoleStore } from './store'
33
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './types'
4+
export { extractChildWorkflowEntries, hasChildTraceSpans } from './utils'

apps/sim/stores/terminal/console/store.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,11 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
224224

225225
const newEntry = get().entries[0]
226226

227-
if (newEntry?.error && newEntry.blockType !== 'cancelled') {
227+
if (
228+
newEntry?.error &&
229+
newEntry.blockType !== 'cancelled' &&
230+
!newEntry.parentWorkflowBlockId
231+
) {
228232
notifyBlockError({
229233
error: newEntry.error,
230234
blockName: newEntry.blockName || 'Unknown Block',
@@ -249,7 +253,9 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
249253
})),
250254

251255
exportConsoleCSV: (workflowId: string) => {
252-
const entries = get().entries.filter((entry) => entry.workflowId === workflowId)
256+
const entries = get().entries.filter(
257+
(entry) => entry.workflowId === workflowId && !entry.parentWorkflowBlockId
258+
)
253259

254260
if (entries.length === 0) {
255261
return

0 commit comments

Comments
 (0)