Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,21 +88,38 @@ export function useTerminalFilters() {
let result = entries

if (hasActiveFilters) {
result = entries.filter((entry) => {
// Block ID filter
// Determine which top-level entries pass the filters
const visibleBlockIds = new Set<string>()
for (const entry of entries) {
if (entry.parentWorkflowBlockId) continue

let passes = true
if (filters.blockIds.size > 0 && !filters.blockIds.has(entry.blockId)) {
return false
passes = false
}

// Status filter
if (filters.statuses.size > 0) {
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)
}
}

// 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)
}
}
}
Comment on lines +112 to +120
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quadratic worst-case in filter propagation

This fixed-point while loop iterates over all entries on every pass until convergence, giving O(N × D) complexity where D is nesting depth. For typical use this is fine, but if a workflow has many entries and deep nesting, this could become slow. Consider building a parent→children adjacency map once and doing a single BFS/DFS pass from the visible roots instead:

Suggested change
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)
}
}
}
// Propagate visibility to child workflow entries via BFS from visible roots.
const childrenOf = new Map<string, string[]>()
for (const entry of entries) {
if (entry.parentWorkflowBlockId) {
const siblings = childrenOf.get(entry.parentWorkflowBlockId) || []
siblings.push(entry.blockId)
childrenOf.set(entry.parentWorkflowBlockId, siblings)
}
}
const queue = [...visibleBlockIds]
for (const parentId of queue) {
for (const childId of childrenOf.get(parentId) || []) {
if (!visibleBlockIds.has(childId)) {
visibleBlockIds.add(childId)
queue.push(childId)
}
}
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


return true
})
result = entries.filter((entry) => visibleBlockIds.has(entry.blockId))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status filtering leaks unrelated entries

Medium Severity

filterEntries now filters by visibleBlockIds instead of entry-level matches, so a single matching entry makes every entry with the same blockId visible. With status filters, this includes entries whose error state does not match the selected status, producing incorrect filtered results across executions.

Fix in Cursor Fix in Web

}

// Sort by executionOrder (monotonically increasing integer from server)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested workflow logs dropped in subflows

Medium Severity

Child workflow entries are attached only when building regularNodes. Entries with iterationType are rendered via iteration block nodes and never call buildWorkflowChildNodes, so child spans for workflow blocks inside loops/parallels are omitted from the terminal tree.

Additional Locations (1)

Fix in Cursor Fix in Web

}
return {
entry,
children: [],
nodeType: 'block' as const,
}
})

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