mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-15 08:55:05 -05:00
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>
This commit is contained in:
@@ -88,21 +88,38 @@ export function useTerminalFilters() {
|
||||
let result = entries
|
||||
|
||||
if (hasActiveFilters) {
|
||||
result = entries.filter((entry) => {
|
||||
// Block ID filter
|
||||
if (filters.blockIds.size > 0 && !filters.blockIds.has(entry.blockId)) {
|
||||
return false
|
||||
}
|
||||
// Determine which top-level entries pass the filters
|
||||
const visibleBlockIds = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
if (entry.parentWorkflowBlockId) continue
|
||||
|
||||
// Status filter
|
||||
if (filters.statuses.size > 0) {
|
||||
let passes = true
|
||||
if (filters.blockIds.size > 0 && !filters.blockIds.has(entry.blockId)) {
|
||||
passes = false
|
||||
}
|
||||
if (passes && filters.statuses.size > 0) {
|
||||
const isError = !!entry.error
|
||||
const hasStatus = isError ? filters.statuses.has('error') : filters.statuses.has('info')
|
||||
if (!hasStatus) return false
|
||||
if (!hasStatus) passes = false
|
||||
}
|
||||
if (passes) {
|
||||
visibleBlockIds.add(entry.blockId)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
// Propagate visibility to child workflow entries (handles arbitrary nesting).
|
||||
// Keep iterating until no new children are discovered.
|
||||
let prevSize = 0
|
||||
while (visibleBlockIds.size !== prevSize) {
|
||||
prevSize = visibleBlockIds.size
|
||||
for (const entry of entries) {
|
||||
if (entry.parentWorkflowBlockId && visibleBlockIds.has(entry.parentWorkflowBlockId)) {
|
||||
visibleBlockIds.add(entry.blockId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = entries.filter((entry) => visibleBlockIds.has(entry.blockId))
|
||||
}
|
||||
|
||||
// Sort by executionOrder (monotonically increasing integer from server)
|
||||
|
||||
@@ -339,7 +339,8 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
|
||||
})
|
||||
|
||||
/**
|
||||
* Entry node component - dispatches to appropriate component based on node type
|
||||
* Entry node component - dispatches to appropriate component based on node type.
|
||||
* Handles recursive rendering for workflow nodes with arbitrarily nested children.
|
||||
*/
|
||||
const EntryNodeRow = memo(function EntryNodeRow({
|
||||
node,
|
||||
@@ -380,6 +381,98 @@ const EntryNodeRow = memo(function EntryNodeRow({
|
||||
)
|
||||
}
|
||||
|
||||
if (nodeType === 'workflow') {
|
||||
const { entry, children } = node
|
||||
const BlockIcon = getBlockIcon(entry.blockType)
|
||||
const hasError = Boolean(entry.error) || children.some((c) => c.entry.error)
|
||||
const bgColor = getBlockColor(entry.blockType)
|
||||
const nodeId = entry.id
|
||||
const isExpanded = expandedNodes.has(nodeId)
|
||||
const hasChildren = children.length > 0
|
||||
const isSelected = selectedEntryId === entry.id
|
||||
const isRunning = Boolean(entry.isRunning)
|
||||
const isCanceled = Boolean(entry.isCanceled)
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
{/* Workflow Block Header */}
|
||||
<div
|
||||
data-entry-id={entry.id}
|
||||
className={clsx(
|
||||
ROW_STYLES.base,
|
||||
'h-[26px]',
|
||||
isSelected ? ROW_STYLES.selected : ROW_STYLES.hover
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (hasChildren) {
|
||||
onToggleNode(nodeId)
|
||||
}
|
||||
onSelectEntry(entry)
|
||||
}}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<div
|
||||
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
'min-w-0 truncate font-medium text-[13px]',
|
||||
hasError
|
||||
? 'text-[var(--text-error)]'
|
||||
: isSelected || isExpanded
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{entry.blockName}
|
||||
</span>
|
||||
{hasChildren && (
|
||||
<ChevronDown
|
||||
className={clsx(
|
||||
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
|
||||
!isExpanded && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex-shrink-0 font-medium text-[13px]',
|
||||
!isRunning &&
|
||||
(isCanceled ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]')
|
||||
)}
|
||||
>
|
||||
<StatusDisplay
|
||||
isRunning={isRunning}
|
||||
isCanceled={isCanceled}
|
||||
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nested Child Workflow Blocks (recursive) */}
|
||||
{isExpanded && hasChildren && (
|
||||
<div className={ROW_STYLES.nested}>
|
||||
{children.map((child) => (
|
||||
<EntryNodeRow
|
||||
key={child.entry.id}
|
||||
node={child}
|
||||
selectedEntryId={selectedEntryId}
|
||||
onSelectEntry={onSelectEntry}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggleNode={onToggleNode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Regular block
|
||||
return (
|
||||
<BlockRow
|
||||
@@ -555,6 +648,8 @@ export const Terminal = memo(function Terminal() {
|
||||
const uniqueBlocks = useMemo(() => {
|
||||
const blocksMap = new Map<string, { blockId: string; blockName: string; blockType: string }>()
|
||||
allWorkflowEntries.forEach((entry) => {
|
||||
// Skip child workflow entries — they use synthetic IDs and shouldn't appear in filters
|
||||
if (entry.parentWorkflowBlockId) return
|
||||
if (!blocksMap.has(entry.blockId)) {
|
||||
blocksMap.set(entry.blockId, {
|
||||
blockId: entry.blockId,
|
||||
@@ -667,19 +762,22 @@ export const Terminal = memo(function Terminal() {
|
||||
|
||||
const newestExec = executionGroups[0]
|
||||
|
||||
// Collect all node IDs that should be expanded (subflows and their iterations)
|
||||
// Collect all expandable node IDs recursively (subflows, iterations, and workflow nodes)
|
||||
const nodeIdsToExpand: string[] = []
|
||||
for (const node of newestExec.entryTree) {
|
||||
if (node.nodeType === 'subflow' && node.children.length > 0) {
|
||||
nodeIdsToExpand.push(node.entry.id)
|
||||
// Also expand all iteration children
|
||||
for (const iterNode of node.children) {
|
||||
if (iterNode.nodeType === 'iteration') {
|
||||
nodeIdsToExpand.push(iterNode.entry.id)
|
||||
}
|
||||
const collectExpandableNodes = (nodes: EntryNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.children.length === 0) continue
|
||||
if (
|
||||
node.nodeType === 'subflow' ||
|
||||
node.nodeType === 'iteration' ||
|
||||
node.nodeType === 'workflow'
|
||||
) {
|
||||
nodeIdsToExpand.push(node.entry.id)
|
||||
collectExpandableNodes(node.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
collectExpandableNodes(newestExec.entryTree)
|
||||
|
||||
if (nodeIdsToExpand.length > 0) {
|
||||
setExpandedNodes((prev) => {
|
||||
|
||||
@@ -120,10 +120,10 @@ export function isSubflowBlockType(blockType: string): boolean {
|
||||
/**
|
||||
* Node type for the tree structure
|
||||
*/
|
||||
export type EntryNodeType = 'block' | 'subflow' | 'iteration'
|
||||
export type EntryNodeType = 'block' | 'subflow' | 'iteration' | 'workflow'
|
||||
|
||||
/**
|
||||
* Entry node for tree structure - represents a block, subflow, or iteration
|
||||
* Entry node for tree structure - represents a block, subflow, iteration, or workflow
|
||||
*/
|
||||
export interface EntryNode {
|
||||
/** The console entry (for blocks) or synthetic entry (for subflows/iterations) */
|
||||
@@ -175,12 +175,17 @@ interface IterationGroup {
|
||||
* Sorts by start time to ensure chronological order.
|
||||
*/
|
||||
function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
// Separate regular blocks from iteration entries
|
||||
// Separate regular blocks from iteration entries and child workflow entries
|
||||
const regularBlocks: ConsoleEntry[] = []
|
||||
const iterationEntries: ConsoleEntry[] = []
|
||||
const childWorkflowEntries = new Map<string, ConsoleEntry[]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.iterationType && entry.iterationCurrent !== undefined) {
|
||||
if (entry.parentWorkflowBlockId) {
|
||||
const existing = childWorkflowEntries.get(entry.parentWorkflowBlockId) || []
|
||||
existing.push(entry)
|
||||
childWorkflowEntries.set(entry.parentWorkflowBlockId, existing)
|
||||
} else if (entry.iterationType && entry.iterationCurrent !== undefined) {
|
||||
iterationEntries.push(entry)
|
||||
} else {
|
||||
regularBlocks.push(entry)
|
||||
@@ -338,12 +343,53 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
})
|
||||
}
|
||||
|
||||
// Build nodes for regular blocks
|
||||
const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({
|
||||
entry,
|
||||
children: [],
|
||||
nodeType: 'block' as const,
|
||||
}))
|
||||
/**
|
||||
* Recursively builds child nodes for workflow blocks.
|
||||
* Handles multi-level nesting where a child workflow block itself has children.
|
||||
*/
|
||||
const buildWorkflowChildNodes = (parentBlockId: string): EntryNode[] => {
|
||||
const childEntries = childWorkflowEntries.get(parentBlockId)
|
||||
if (!childEntries || childEntries.length === 0) return []
|
||||
|
||||
childEntries.sort((a, b) => {
|
||||
const aTime = new Date(a.startedAt || a.timestamp).getTime()
|
||||
const bTime = new Date(b.startedAt || b.timestamp).getTime()
|
||||
return aTime - bTime
|
||||
})
|
||||
|
||||
return childEntries.map((child) => {
|
||||
const nestedChildren = buildWorkflowChildNodes(child.blockId)
|
||||
if (nestedChildren.length > 0) {
|
||||
return {
|
||||
entry: child,
|
||||
children: nestedChildren,
|
||||
nodeType: 'workflow' as const,
|
||||
}
|
||||
}
|
||||
return {
|
||||
entry: child,
|
||||
children: [],
|
||||
nodeType: 'block' as const,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Build nodes for regular blocks, promoting workflow blocks with children to 'workflow' nodes
|
||||
const regularNodes: EntryNode[] = regularBlocks.map((entry) => {
|
||||
const childNodes = buildWorkflowChildNodes(entry.blockId)
|
||||
if (childNodes.length > 0) {
|
||||
return {
|
||||
entry,
|
||||
children: childNodes,
|
||||
nodeType: 'workflow' as const,
|
||||
}
|
||||
}
|
||||
return {
|
||||
entry,
|
||||
children: [],
|
||||
nodeType: 'block' as const,
|
||||
}
|
||||
})
|
||||
|
||||
// Combine all nodes and sort by executionOrder ascending (oldest first, top-down)
|
||||
const allNodes = [...subflowNodes, ...regularNodes]
|
||||
|
||||
@@ -38,7 +38,11 @@ import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/executi
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useVariablesStore } from '@/stores/panel'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal'
|
||||
import {
|
||||
extractChildWorkflowEntries,
|
||||
hasChildTraceSpans,
|
||||
useTerminalConsoleStore,
|
||||
} from '@/stores/terminal'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
@@ -517,6 +521,20 @@ export function useWorkflowExecution() {
|
||||
addConsoleEntry(data, data.output as NormalizedBlockOutput)
|
||||
}
|
||||
|
||||
// Extract child workflow trace spans into separate console entries
|
||||
if (data.blockType === 'workflow' && hasChildTraceSpans(data.output)) {
|
||||
const childEntries = extractChildWorkflowEntries({
|
||||
parentBlockId: data.blockId,
|
||||
executionId: executionIdRef.current,
|
||||
executionOrder: data.executionOrder,
|
||||
workflowId: workflowId!,
|
||||
childTraceSpans: data.output.childTraceSpans,
|
||||
})
|
||||
for (const entry of childEntries) {
|
||||
addConsole(entry)
|
||||
}
|
||||
}
|
||||
|
||||
if (onBlockCompleteCallback) {
|
||||
onBlockCompleteCallback(data.blockId, data.output).catch((error) => {
|
||||
logger.error('Error in onBlockComplete callback:', error)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { ExecutionResult, StreamingExecution } from '@/executor/types'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal'
|
||||
import {
|
||||
extractChildWorkflowEntries,
|
||||
hasChildTraceSpans,
|
||||
useTerminalConsoleStore,
|
||||
} from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -149,6 +153,20 @@ export async function executeWorkflowWithFullLogging(
|
||||
iterationContainerId: event.data.iterationContainerId,
|
||||
})
|
||||
|
||||
// Extract child workflow trace spans into separate console entries
|
||||
if (event.data.blockType === 'workflow' && hasChildTraceSpans(event.data.output)) {
|
||||
const childEntries = extractChildWorkflowEntries({
|
||||
parentBlockId: event.data.blockId,
|
||||
executionId,
|
||||
executionOrder: event.data.executionOrder,
|
||||
workflowId: activeWorkflowId,
|
||||
childTraceSpans: event.data.output.childTraceSpans,
|
||||
})
|
||||
for (const entry of childEntries) {
|
||||
addConsole(entry)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.onBlockComplete) {
|
||||
options.onBlockComplete(event.data.blockId, event.data.output).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { indexedDBStorage } from './storage'
|
||||
export { useTerminalConsoleStore } from './store'
|
||||
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './types'
|
||||
export { extractChildWorkflowEntries, hasChildTraceSpans } from './utils'
|
||||
|
||||
@@ -224,7 +224,11 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
|
||||
const newEntry = get().entries[0]
|
||||
|
||||
if (newEntry?.error && newEntry.blockType !== 'cancelled') {
|
||||
if (
|
||||
newEntry?.error &&
|
||||
newEntry.blockType !== 'cancelled' &&
|
||||
!newEntry.parentWorkflowBlockId
|
||||
) {
|
||||
notifyBlockError({
|
||||
error: newEntry.error,
|
||||
blockName: newEntry.blockName || 'Unknown Block',
|
||||
@@ -249,7 +253,9 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
})),
|
||||
|
||||
exportConsoleCSV: (workflowId: string) => {
|
||||
const entries = get().entries.filter((entry) => entry.workflowId === workflowId)
|
||||
const entries = get().entries.filter(
|
||||
(entry) => entry.workflowId === workflowId && !entry.parentWorkflowBlockId
|
||||
)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ConsoleEntry {
|
||||
iterationTotal?: number
|
||||
iterationType?: SubflowType
|
||||
iterationContainerId?: string
|
||||
parentWorkflowBlockId?: string
|
||||
isRunning?: boolean
|
||||
isCanceled?: boolean
|
||||
}
|
||||
@@ -44,6 +45,7 @@ export interface ConsoleUpdate {
|
||||
iterationTotal?: number
|
||||
iterationType?: SubflowType
|
||||
iterationContainerId?: string
|
||||
parentWorkflowBlockId?: string
|
||||
}
|
||||
|
||||
export interface ConsoleStore {
|
||||
|
||||
78
apps/sim/stores/terminal/console/utils.ts
Normal file
78
apps/sim/stores/terminal/console/utils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import type { ConsoleEntry } from '@/stores/terminal/console/types'
|
||||
|
||||
/**
|
||||
* Parameters for extracting child workflow entries from trace spans
|
||||
*/
|
||||
interface ExtractChildWorkflowEntriesParams {
|
||||
parentBlockId: string
|
||||
executionId: string
|
||||
executionOrder: number
|
||||
workflowId: string
|
||||
childTraceSpans: TraceSpan[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts child workflow trace spans into console entry payloads.
|
||||
* Handles recursive nesting for multi-level child workflows by flattening
|
||||
* nested children with a parent block ID chain.
|
||||
*/
|
||||
export function extractChildWorkflowEntries(
|
||||
params: ExtractChildWorkflowEntriesParams
|
||||
): Omit<ConsoleEntry, 'id' | 'timestamp'>[] {
|
||||
const { parentBlockId, executionId, executionOrder, workflowId, childTraceSpans } = params
|
||||
const entries: Omit<ConsoleEntry, 'id' | 'timestamp'>[] = []
|
||||
|
||||
for (const span of childTraceSpans) {
|
||||
if (!span.blockId) continue
|
||||
|
||||
const childBlockId = `child-${parentBlockId}-${span.blockId}`
|
||||
|
||||
entries.push({
|
||||
blockId: childBlockId,
|
||||
blockName: span.name || 'Unknown Block',
|
||||
blockType: span.type || 'unknown',
|
||||
parentWorkflowBlockId: parentBlockId,
|
||||
input: span.input || {},
|
||||
output: (span.output || {}) as ConsoleEntry['output'],
|
||||
durationMs: span.duration,
|
||||
startedAt: span.startTime,
|
||||
endedAt: span.endTime,
|
||||
success: span.status !== 'error',
|
||||
error:
|
||||
span.status === 'error'
|
||||
? (span.output?.error as string) || `${span.name || 'Block'} failed`
|
||||
: undefined,
|
||||
executionId,
|
||||
executionOrder,
|
||||
workflowId,
|
||||
})
|
||||
|
||||
// Recursively extract nested child workflow spans
|
||||
if (span.children && span.children.length > 0 && span.type === 'workflow') {
|
||||
const nestedEntries = extractChildWorkflowEntries({
|
||||
parentBlockId: childBlockId,
|
||||
executionId,
|
||||
executionOrder,
|
||||
workflowId,
|
||||
childTraceSpans: span.children,
|
||||
})
|
||||
entries.push(...nestedEntries)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block completed event output contains child trace spans
|
||||
*/
|
||||
export function hasChildTraceSpans(output: unknown): output is Record<string, unknown> & {
|
||||
childTraceSpans: TraceSpan[]
|
||||
} {
|
||||
return (
|
||||
output !== null &&
|
||||
typeof output === 'object' &&
|
||||
Array.isArray((output as Record<string, unknown>).childTraceSpans)
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './console'
|
||||
export { useTerminalConsoleStore } from './console'
|
||||
export { extractChildWorkflowEntries, hasChildTraceSpans, useTerminalConsoleStore } from './console'
|
||||
export { useTerminalStore } from './store'
|
||||
export type { TerminalState } from './types'
|
||||
|
||||
Reference in New Issue
Block a user