feat(terminal): log view

This commit is contained in:
Emir Karabeg
2026-01-28 00:53:45 -08:00
parent 9eff58ab45
commit 86653cadb5
22 changed files with 2108 additions and 952 deletions

View File

@@ -573,7 +573,19 @@ const TraceSpanNode = memo(function TraceSpanNode({
return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
}, [span, spanId, spanStartTime])
const hasChildren = allChildren.length > 0
// Hide empty model timing segments for agents without tool calls
const filteredChildren = useMemo(() => {
const isAgent = span.type?.toLowerCase() === 'agent'
const hasToolCalls =
(span.toolCalls?.length ?? 0) > 0 || allChildren.some((c) => c.type?.toLowerCase() === 'tool')
if (isAgent && !hasToolCalls) {
return allChildren.filter((c) => c.type?.toLowerCase() !== 'model')
}
return allChildren
}, [allChildren, span.type, span.toolCalls])
const hasChildren = filteredChildren.length > 0
const isExpanded = isRootWorkflow || expandedNodes.has(spanId)
const isToggleable = !isRootWorkflow
@@ -685,7 +697,7 @@ const TraceSpanNode = memo(function TraceSpanNode({
{/* Nested Children */}
{hasChildren && (
<div className='flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[10px]'>
{allChildren.map((child, index) => (
{filteredChildren.map((child, index) => (
<div key={child.id || `${spanId}-child-${index}`} className='pl-[6px]'>
<TraceSpanNode
span={child}

View File

@@ -3,8 +3,9 @@
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { ChevronDown, RepeatIcon, SplitIcon } from 'lucide-react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import { ChevronDown } from '@/components/emcn'
import {
FieldItem,
type SchemaField,
@@ -115,9 +116,8 @@ function ConnectionItem({
{hasFields && (
<ChevronDown
className={clsx(
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]',
isExpanded && 'rotate-180'
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
!isExpanded && '-rotate-90'
)}
/>
)}

View File

@@ -0,0 +1,160 @@
'use client'
import { memo } from 'react'
import clsx from 'clsx'
import { Filter } from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverDivider,
PopoverItem,
PopoverScrollArea,
PopoverSection,
PopoverTrigger,
} from '@/components/emcn'
import type {
BlockInfo,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import {
formatRunId,
getBlockIcon,
getRunIdColor,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils'
/**
* Props for the FilterPopover component
*/
export interface FilterPopoverProps {
open: boolean
onOpenChange: (open: boolean) => void
filters: TerminalFilters
toggleStatus: (status: 'error' | 'info') => void
toggleBlock: (blockId: string) => void
toggleRunId: (runId: string) => void
uniqueBlocks: BlockInfo[]
uniqueRunIds: string[]
executionColorMap: Map<string, string>
hasActiveFilters: boolean
}
/**
* Filter popover component used in terminal header and output panel
*/
export const FilterPopover = memo(function FilterPopover({
open,
onOpenChange,
filters,
toggleStatus,
toggleBlock,
toggleRunId,
uniqueBlocks,
uniqueRunIds,
executionColorMap,
hasActiveFilters,
}: FilterPopoverProps) {
return (
<Popover open={open} onOpenChange={onOpenChange} size='sm'>
<PopoverTrigger asChild>
<Button
variant='ghost'
className='!p-1.5 -m-1.5'
onClick={(e) => e.stopPropagation()}
aria-label='Filters'
>
<Filter
className={clsx('h-3 w-3', hasActiveFilters && 'text-[var(--brand-secondary)]')}
/>
</Button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={4}
onClick={(e) => e.stopPropagation()}
minWidth={160}
maxWidth={220}
maxHeight={300}
>
<PopoverSection>Status</PopoverSection>
<PopoverItem
active={filters.statuses.has('error')}
showCheck={filters.statuses.has('error')}
onClick={() => toggleStatus('error')}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: 'var(--text-error)' }}
/>
<span className='flex-1'>Error</span>
</PopoverItem>
<PopoverItem
active={filters.statuses.has('info')}
showCheck={filters.statuses.has('info')}
onClick={() => toggleStatus('info')}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: 'var(--terminal-status-info-color)' }}
/>
<span className='flex-1'>Info</span>
</PopoverItem>
{uniqueBlocks.length > 0 && (
<>
<PopoverDivider />
<PopoverSection className='!mt-0'>Blocks</PopoverSection>
<PopoverScrollArea className='max-h-[100px]'>
{uniqueBlocks.map((block) => {
const BlockIcon = getBlockIcon(block.blockType)
const isSelected = filters.blockIds.has(block.blockId)
return (
<PopoverItem
key={block.blockId}
active={isSelected}
showCheck={isSelected}
onClick={() => toggleBlock(block.blockId)}
>
{BlockIcon && <BlockIcon className='h-3 w-3' />}
<span className='flex-1'>{block.blockName}</span>
</PopoverItem>
)
})}
</PopoverScrollArea>
</>
)}
{uniqueRunIds.length > 0 && (
<>
<PopoverDivider />
<PopoverSection className='!mt-0'>Run ID</PopoverSection>
<PopoverScrollArea className='max-h-[100px]'>
{uniqueRunIds.map((runId) => {
const isSelected = filters.runIds.has(runId)
const runIdColor = getRunIdColor(runId, executionColorMap)
return (
<PopoverItem
key={runId}
active={isSelected}
showCheck={isSelected}
onClick={() => toggleRunId(runId)}
>
<span
className='flex-1 font-mono text-[11px]'
style={{ color: runIdColor || '#D2D2D2' }}
>
{formatRunId(runId)}
</span>
</PopoverItem>
)
})}
</PopoverScrollArea>
</>
)}
</PopoverContent>
</Popover>
)
})

View File

@@ -0,0 +1 @@
export { FilterPopover, type FilterPopoverProps } from './filter-popover'

View File

@@ -1,2 +1,4 @@
export { LogRowContextMenu } from './log-row-context-menu'
export { OutputPanel } from './output-panel'
export { FilterPopover, type FilterPopoverProps } from './filter-popover'
export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu'
export { OutputPanel, type OutputPanelProps } from './output-panel'
export { ToggleButton, type ToggleButtonProps } from './toggle-button'

View File

@@ -0,0 +1 @@
export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu'

View File

@@ -1,6 +1,6 @@
'use client'
import type { RefObject } from 'react'
import { memo, type RefObject } from 'react'
import {
Popover,
PopoverAnchor,
@@ -8,20 +8,13 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type {
ContextMenuPosition,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import type { ConsoleEntry } from '@/stores/terminal'
interface ContextMenuPosition {
x: number
y: number
}
interface TerminalFilters {
blockIds: Set<string>
statuses: Set<'error' | 'info'>
runIds: Set<string>
}
interface LogRowContextMenuProps {
export interface LogRowContextMenuProps {
isOpen: boolean
position: ContextMenuPosition
menuRef: RefObject<HTMLDivElement | null>
@@ -42,7 +35,7 @@ interface LogRowContextMenuProps {
* Context menu for terminal log rows (left side).
* Displays filtering options based on the selected row's properties.
*/
export function LogRowContextMenu({
export const LogRowContextMenu = memo(function LogRowContextMenu({
isOpen,
position,
menuRef,
@@ -173,4 +166,4 @@ export function LogRowContextMenu({
</PopoverContent>
</Popover>
)
}
})

View File

@@ -1,6 +1,6 @@
'use client'
import type { RefObject } from 'react'
import { memo, type RefObject } from 'react'
import {
Popover,
PopoverAnchor,
@@ -8,13 +8,9 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { ContextMenuPosition } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
interface ContextMenuPosition {
x: number
y: number
}
interface OutputContextMenuProps {
export interface OutputContextMenuProps {
isOpen: boolean
position: ContextMenuPosition
menuRef: RefObject<HTMLDivElement | null>
@@ -36,7 +32,7 @@ interface OutputContextMenuProps {
* Context menu for terminal output panel (right side).
* Displays copy, search, and display options for the code viewer.
*/
export function OutputContextMenu({
export const OutputContextMenu = memo(function OutputContextMenu({
isOpen,
position,
menuRef,
@@ -123,4 +119,4 @@ export function OutputContextMenu({
</PopoverContent>
</Popover>
)
}
})

View File

@@ -1,9 +1,17 @@
'use client'
import type React from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { Badge } from '@/components/emcn'
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Badge, ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
type ValueType = 'null' | 'undefined' | 'array' | 'string' | 'number' | 'boolean' | 'object'
@@ -15,13 +23,19 @@ interface NodeEntry {
path: string
}
/** Search context passed through the component tree */
interface SearchContext {
/**
* Search context for the structured output tree.
* Separates stable values (query, pathToMatchIndices) from frequently changing currentMatchIndex
* to avoid unnecessary re-renders of the entire tree.
*/
interface SearchContextValue {
query: string
currentMatchIndex: number
pathToMatchIndices: Map<string, number[]>
currentMatchIndexRef: React.RefObject<number>
}
const SearchContext = createContext<SearchContextValue | null>(null)
const BADGE_VARIANTS: Record<ValueType, BadgeVariant> = {
string: 'green',
number: 'blue',
@@ -33,16 +47,17 @@ const BADGE_VARIANTS: Record<ValueType, BadgeVariant> = {
} as const
const STYLES = {
row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] rounded-[8px] px-[6px] -mx-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
chevron:
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
keyName:
'font-medium text-[13px] text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]',
badge: 'rounded-[4px] px-[4px] py-[0px] font-mono text-[11px]',
summary: 'font-mono text-[12px] text-[var(--text-tertiary)]',
indent: 'ml-[3px] border-[var(--border)] border-l pl-[9px]',
value: 'py-[2px] font-mono text-[13px] text-[var(--text-secondary)]',
emptyValue: 'py-[2px] font-mono text-[13px] text-[var(--text-tertiary)]',
'font-medium text-[13px] text-[var(--text-primary)] group-hover:text-[var(--text-primary)]',
badge: 'rounded-[4px] px-[4px] py-[0px] text-[11px]',
summary: 'text-[12px] text-[var(--text-tertiary)]',
indent:
'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]',
value: 'py-[2px] text-[13px] text-[var(--text-primary)]',
emptyValue: 'py-[2px] text-[13px] text-[var(--text-tertiary)]',
matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40',
currentMatchHighlight: 'bg-orange-400',
} as const
@@ -51,8 +66,6 @@ const EMPTY_MATCH_INDICES: number[] = []
/**
* Returns the type label for a value
* @param value - The value to get the type label for
* @returns The type label string
*/
function getTypeLabel(value: unknown): ValueType {
if (value === null) return 'null'
@@ -63,8 +76,6 @@ function getTypeLabel(value: unknown): ValueType {
/**
* Formats a primitive value for display
* @param value - The primitive value to format
* @returns The formatted string representation
*/
function formatPrimitive(value: unknown): string {
if (value === null) return 'null'
@@ -74,8 +85,6 @@ function formatPrimitive(value: unknown): string {
/**
* Checks if a value is a primitive (not object/array)
* @param value - The value to check
* @returns True if the value is a primitive
*/
function isPrimitive(value: unknown): value is null | undefined | string | number | boolean {
return value === null || value === undefined || typeof value !== 'object'
@@ -83,8 +92,6 @@ function isPrimitive(value: unknown): value is null | undefined | string | numbe
/**
* Checks if a value is an empty object or array
* @param value - The value to check
* @returns True if the value is empty
*/
function isEmpty(value: unknown): boolean {
if (Array.isArray(value)) return value.length === 0
@@ -94,8 +101,6 @@ function isEmpty(value: unknown): boolean {
/**
* Extracts error message from various error data formats
* @param data - The error data to extract message from
* @returns The extracted error message string
*/
function extractErrorMessage(data: unknown): string {
if (typeof data === 'string') return data
@@ -108,9 +113,6 @@ function extractErrorMessage(data: unknown): string {
/**
* Builds node entries from an object or array value
* @param value - The object or array to build entries from
* @param basePath - The base path for constructing child paths
* @returns Array of node entries
*/
function buildEntries(value: unknown, basePath: string): NodeEntry[] {
if (Array.isArray(value)) {
@@ -125,8 +127,6 @@ function buildEntries(value: unknown, basePath: string): NodeEntry[] {
/**
* Gets the count summary for collapsed arrays/objects
* @param value - The array or object to summarize
* @returns Summary string or null for primitives
*/
function getCollapsedSummary(value: unknown): string | null {
if (Array.isArray(value)) {
@@ -142,9 +142,6 @@ function getCollapsedSummary(value: unknown): string | null {
/**
* Computes initial expanded paths for first-level items
* @param data - The data to compute paths for
* @param isError - Whether this is error data
* @returns Set of initially expanded paths
*/
function computeInitialPaths(data: unknown, isError: boolean): Set<string> {
if (isError) return new Set(['root.error'])
@@ -157,8 +154,6 @@ function computeInitialPaths(data: unknown, isError: boolean): Set<string> {
/**
* Gets all ancestor paths needed to reach a given path
* @param path - The target path
* @returns Array of ancestor paths
*/
function getAncestorPaths(path: string): string[] {
const ancestors: string[] = []
@@ -176,9 +171,6 @@ function getAncestorPaths(path: string): string[] {
/**
* Finds all case-insensitive matches of a query within text
* @param text - The text to search in
* @param query - The search query
* @returns Array of [startIndex, endIndex] tuples
*/
function findTextMatches(text: string, query: string): Array<[number, number]> {
if (!query) return []
@@ -200,10 +192,6 @@ function findTextMatches(text: string, query: string): Array<[number, number]> {
/**
* Adds match entries for a primitive value at the given path
* @param value - The primitive value
* @param path - The path to this value
* @param query - The search query
* @param matches - The matches array to add to
*/
function addPrimitiveMatches(value: unknown, path: string, query: string, matches: string[]): void {
const text = formatPrimitive(value)
@@ -215,10 +203,6 @@ function addPrimitiveMatches(value: unknown, path: string, query: string, matche
/**
* Recursively collects all match paths across the entire data tree
* @param data - The data to search
* @param query - The search query
* @param basePath - The base path for this level
* @returns Array of paths where matches were found
*/
function collectAllMatchPaths(data: unknown, query: string, basePath: string): string[] {
if (!query) return []
@@ -243,8 +227,6 @@ function collectAllMatchPaths(data: unknown, query: string, basePath: string): s
/**
* Builds a map from path to array of global match indices
* @param matchPaths - Array of paths where matches occur
* @returns Map from path to array of global indices
*/
function buildPathToIndicesMap(matchPaths: string[]): Map<string, number[]> {
const map = new Map<string, number[]>()
@@ -261,25 +243,28 @@ function buildPathToIndicesMap(matchPaths: string[]): Map<string, number[]> {
interface HighlightedTextProps {
text: string
searchQuery: string | undefined
matchIndices: number[]
currentMatchIndex: number
path: string
}
/**
* Renders text with search highlights
* Renders text with search highlights.
* Uses context to access search state and avoid prop drilling.
*/
const HighlightedText = memo(function HighlightedText({
text,
searchQuery,
matchIndices,
currentMatchIndex,
path,
}: HighlightedTextProps) {
if (!searchQuery || matchIndices.length === 0) return <>{text}</>
const searchContext = useContext(SearchContext)
const textMatches = findTextMatches(text, searchQuery)
if (!searchContext || matchIndices.length === 0) return <>{text}</>
const textMatches = findTextMatches(text, searchContext.query)
if (textMatches.length === 0) return <>{text}</>
const currentMatchIndex = searchContext.currentMatchIndexRef.current
const segments: React.ReactNode[] = []
let lastEnd = 0
@@ -288,12 +273,12 @@ const HighlightedText = memo(function HighlightedText({
const isCurrent = globalIndex === currentMatchIndex
if (start > lastEnd) {
segments.push(<span key={`t-${start}`}>{text.slice(lastEnd, start)}</span>)
segments.push(<span key={`t-${path}-${start}`}>{text.slice(lastEnd, start)}</span>)
}
segments.push(
<mark
key={`m-${start}`}
key={`m-${path}-${start}`}
data-search-match
data-match-index={globalIndex}
className={cn(
@@ -308,7 +293,7 @@ const HighlightedText = memo(function HighlightedText({
})
if (lastEnd < text.length) {
segments.push(<span key={`t-${lastEnd}`}>{text.slice(lastEnd)}</span>)
segments.push(<span key={`t-${path}-${lastEnd}`}>{text.slice(lastEnd)}</span>)
}
return <>{segments}</>
@@ -322,11 +307,11 @@ interface StructuredNodeProps {
onToggle: (path: string) => void
wrapText: boolean
isError?: boolean
searchContext?: SearchContext
}
/**
* Recursive node component for rendering structured data
* Recursive node component for rendering structured data.
* Uses context for search state to avoid re-renders when currentMatchIndex changes.
*/
const StructuredNode = memo(function StructuredNode({
name,
@@ -336,8 +321,8 @@ const StructuredNode = memo(function StructuredNode({
onToggle,
wrapText,
isError = false,
searchContext,
}: StructuredNodeProps) {
const searchContext = useContext(SearchContext)
const type = getTypeLabel(value)
const isPrimitiveValue = isPrimitive(value)
const isEmptyValue = !isPrimitiveValue && isEmpty(value)
@@ -379,7 +364,6 @@ const StructuredNode = memo(function StructuredNode({
tabIndex={0}
aria-expanded={isExpanded}
>
<ChevronDown className={cn(STYLES.chevron, !isExpanded && '-rotate-90')} />
<span className={cn(STYLES.keyName, isError && 'text-[var(--text-error)]')}>{name}</span>
<Badge variant={badgeVariant} className={STYLES.badge}>
{type}
@@ -387,6 +371,7 @@ const StructuredNode = memo(function StructuredNode({
{!isExpanded && collapsedSummary && (
<span className={STYLES.summary}>{collapsedSummary}</span>
)}
<ChevronDown className={cn(STYLES.chevron, !isExpanded && '-rotate-90')} />
</div>
{isExpanded && (
@@ -398,12 +383,7 @@ const StructuredNode = memo(function StructuredNode({
wrapText ? '[word-break:break-word]' : 'whitespace-nowrap'
)}
>
<HighlightedText
text={valueText}
searchQuery={searchContext?.query}
matchIndices={matchIndices}
currentMatchIndex={searchContext?.currentMatchIndex ?? -1}
/>
<HighlightedText text={valueText} matchIndices={matchIndices} path={path} />
</div>
) : isEmptyValue ? (
<div className={STYLES.emptyValue}>{Array.isArray(value) ? '[]' : '{}'}</div>
@@ -417,7 +397,6 @@ const StructuredNode = memo(function StructuredNode({
expandedPaths={expandedPaths}
onToggle={onToggle}
wrapText={wrapText}
searchContext={searchContext}
/>
))
)}
@@ -427,10 +406,11 @@ const StructuredNode = memo(function StructuredNode({
)
})
interface StructuredOutputProps {
export interface StructuredOutputProps {
data: unknown
wrapText?: boolean
isError?: boolean
isRunning?: boolean
className?: string
searchQuery?: string
currentMatchIndex?: number
@@ -441,11 +421,13 @@ interface StructuredOutputProps {
/**
* Renders structured data as nested collapsible blocks.
* Supports search with highlighting, auto-expand, and scroll-to-match.
* Uses React Context for search state to prevent re-render cascade.
*/
export const StructuredOutput = memo(function StructuredOutput({
data,
wrapText = true,
isError = false,
isRunning = false,
className,
searchQuery,
currentMatchIndex = 0,
@@ -458,6 +440,16 @@ export const StructuredOutput = memo(function StructuredOutput({
const prevDataRef = useRef(data)
const prevIsErrorRef = useRef(isError)
const internalRef = useRef<HTMLDivElement>(null)
const currentMatchIndexRef = useRef(currentMatchIndex)
// Keep ref in sync
currentMatchIndexRef.current = currentMatchIndex
// Force re-render of highlighted text when currentMatchIndex changes
const [, forceUpdate] = useState(0)
useEffect(() => {
forceUpdate((n) => n + 1)
}, [currentMatchIndex])
const setContainerRef = useCallback(
(node: HTMLDivElement | null) => {
@@ -545,44 +537,73 @@ export const StructuredOutput = memo(function StructuredOutput({
return buildEntries(data, 'root')
}, [data])
const searchContext = useMemo<SearchContext | undefined>(() => {
if (!searchQuery) return undefined
return { query: searchQuery, currentMatchIndex, pathToMatchIndices }
}, [searchQuery, currentMatchIndex, pathToMatchIndices])
// Create stable search context value - only changes when query or pathToMatchIndices change
const searchContextValue = useMemo<SearchContextValue | null>(() => {
if (!searchQuery) return null
return {
query: searchQuery,
pathToMatchIndices,
currentMatchIndexRef,
}
}, [searchQuery, pathToMatchIndices])
const containerClass = cn('flex flex-col pl-[20px]', className)
if (isError) {
// Show "Running" badge when running with undefined data
if (isRunning && data === undefined) {
return (
<div ref={setContainerRef} className={containerClass}>
<StructuredNode
name='error'
value={extractErrorMessage(data)}
path='root.error'
expandedPaths={expandedPaths}
onToggle={handleToggle}
wrapText={wrapText}
isError
searchContext={searchContext}
/>
<div className={STYLES.row}>
<span className={STYLES.keyName}>running</span>
<Badge variant='green' className={STYLES.badge}>
Running
</Badge>
</div>
</div>
)
}
if (isError) {
return (
<SearchContext.Provider value={searchContextValue}>
<div ref={setContainerRef} className={containerClass}>
<StructuredNode
name='error'
value={extractErrorMessage(data)}
path='root.error'
expandedPaths={expandedPaths}
onToggle={handleToggle}
wrapText={wrapText}
isError
/>
</div>
</SearchContext.Provider>
)
}
if (rootEntries.length === 0) {
return (
<div ref={setContainerRef} className={containerClass}>
<span className={STYLES.emptyValue}>null</span>
</div>
)
}
return (
<div ref={setContainerRef} className={containerClass}>
{rootEntries.map((entry) => (
<StructuredNode
key={entry.path}
name={entry.key}
value={entry.value}
path={entry.path}
expandedPaths={expandedPaths}
onToggle={handleToggle}
wrapText={wrapText}
searchContext={searchContext}
/>
))}
</div>
<SearchContext.Provider value={searchContextValue}>
<div ref={setContainerRef} className={containerClass}>
{rootEntries.map((entry) => (
<StructuredNode
key={entry.path}
name={entry.key}
value={entry.value}
path={entry.path}
expandedPaths={expandedPaths}
onToggle={handleToggle}
wrapText={wrapText}
/>
))}
</div>
</SearchContext.Provider>
)
})

View File

@@ -1,2 +1,4 @@
export { OutputContextMenu, type OutputContextMenuProps } from './components/output-context-menu'
export { StructuredOutput, type StructuredOutputProps } from './components/structured-output'
export type { OutputPanelProps } from './output-panel'
export { OutputPanel } from './output-panel'

View File

@@ -1,13 +1,12 @@
'use client'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
ArrowDown,
ArrowDownToLine,
ArrowUp,
Check,
ChevronDown,
Clipboard,
Database,
FilterX,
@@ -29,11 +28,18 @@ import {
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { FilterPopover } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover'
import { OutputContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu'
import { StructuredOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output'
import { ToggleButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button'
import type {
BlockInfo,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import type { ConsoleEntry } from '@/stores/terminal'
import { useTerminalStore } from '@/stores/terminal'
interface OutputCodeContentProps {
code: string
@@ -73,32 +79,13 @@ const OutputCodeContent = React.memo(function OutputCodeContent({
)
})
/**
* Reusable toggle button component
*/
const ToggleButton = ({
isExpanded,
onClick,
}: {
isExpanded: boolean
onClick: (e: React.MouseEvent) => void
}) => (
<Button variant='ghost' className='!p-1.5 -m-1.5' onClick={onClick} aria-label='Toggle terminal'>
<ChevronDown
className={clsx(
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
!isExpanded && 'rotate-180'
)}
/>
</Button>
)
/**
* Props for the OutputPanel component
* Store-backed settings (wrapText, openOnRun, structuredView, outputPanelWidth)
* are accessed directly from useTerminalStore to reduce prop drilling.
*/
export interface OutputPanelProps {
selectedEntry: ConsoleEntry
outputPanelWidth: number
handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void
handleHeaderClick: () => void
isExpanded: boolean
@@ -117,26 +104,25 @@ export interface OutputPanelProps {
hasActiveFilters: boolean
clearFilters: () => void
handleClearConsole: (e: React.MouseEvent) => void
wrapText: boolean
setWrapText: (wrap: boolean) => void
openOnRun: boolean
setOpenOnRun: (open: boolean) => void
structuredView: boolean
setStructuredView: (structured: boolean) => void
outputOptionsOpen: boolean
setOutputOptionsOpen: (open: boolean) => void
shouldShowCodeDisplay: boolean
outputDataStringified: string
outputData: unknown
handleClearConsoleFromMenu: () => void
filters: TerminalFilters
toggleBlock: (blockId: string) => void
toggleStatus: (status: 'error' | 'info') => void
toggleRunId: (runId: string) => void
uniqueBlocks: BlockInfo[]
uniqueRunIds: string[]
executionColorMap: Map<string, string>
}
/**
* Output panel component that manages its own search state.
* Accesses store-backed settings directly to reduce prop drilling.
*/
export const OutputPanel = React.memo(function OutputPanel({
selectedEntry,
outputPanelWidth,
handleOutputPanelResizeMouseDown,
handleHeaderClick,
isExpanded,
@@ -155,20 +141,30 @@ export const OutputPanel = React.memo(function OutputPanel({
hasActiveFilters,
clearFilters,
handleClearConsole,
wrapText,
setWrapText,
openOnRun,
setOpenOnRun,
structuredView,
setStructuredView,
outputOptionsOpen,
setOutputOptionsOpen,
shouldShowCodeDisplay,
outputDataStringified,
outputData,
handleClearConsoleFromMenu,
filters,
toggleBlock,
toggleStatus,
toggleRunId,
uniqueBlocks,
uniqueRunIds,
executionColorMap,
}: OutputPanelProps) {
// Access store-backed settings directly to reduce prop drilling
const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth)
const wrapText = useTerminalStore((state) => state.wrapText)
const setWrapText = useTerminalStore((state) => state.setWrapText)
const openOnRun = useTerminalStore((state) => state.openOnRun)
const setOpenOnRun = useTerminalStore((state) => state.setOpenOnRun)
const structuredView = useTerminalStore((state) => state.structuredView)
const setStructuredView = useTerminalStore((state) => state.setStructuredView)
const outputContentRef = useRef<HTMLDivElement>(null)
const [filtersOpen, setFiltersOpen] = useState(false)
const [outputOptionsOpen, setOutputOptionsOpen] = useState(false)
const {
isSearchActive: isOutputSearchActive,
searchQuery: outputSearchQuery,
@@ -215,6 +211,81 @@ export const OutputPanel = React.memo(function OutputPanel({
}
}, [storedSelectionText])
// Memoized callbacks to avoid inline arrow functions
const handleToggleStructuredView = useCallback(() => {
setStructuredView(!structuredView)
}, [structuredView, setStructuredView])
const handleToggleWrapText = useCallback(() => {
setWrapText(!wrapText)
}, [wrapText, setWrapText])
const handleToggleOpenOnRun = useCallback(() => {
setOpenOnRun(!openOnRun)
}, [openOnRun, setOpenOnRun])
const handleClearFiltersClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
clearFilters()
},
[clearFilters]
)
const handleCopyClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
handleCopy()
},
[handleCopy]
)
const handleSearchClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
activateOutputSearch()
},
[activateOutputSearch]
)
const handleCloseSearchClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
closeOutputSearch()
},
[closeOutputSearch]
)
const handleOutputButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!isExpanded) {
expandToLastHeight()
}
if (showInput) setShowInput(false)
},
[isExpanded, expandToLastHeight, showInput, setShowInput]
)
const handleInputButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!isExpanded) {
expandToLastHeight()
}
setShowInput(true)
},
[isExpanded, expandToLastHeight, setShowInput]
)
const handleToggleButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
handleHeaderClick()
},
[handleHeaderClick]
)
/**
* Track text selection state for context menu.
* Skip updates when the context menu is open to prevent the selection
@@ -232,6 +303,12 @@ export const OutputPanel = React.memo(function OutputPanel({
return () => document.removeEventListener('selectionchange', handleSelectionChange)
}, [isOutputMenuOpen])
// Memoize the search query for structured output to avoid re-renders
const structuredSearchQuery = useMemo(
() => (isOutputSearchActive ? outputSearchQuery : undefined),
[isOutputSearchActive, outputSearchQuery]
)
return (
<>
<div
@@ -259,13 +336,7 @@ export const OutputPanel = React.memo(function OutputPanel({
'px-[8px] py-[6px] text-[12px]',
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={(e) => {
e.stopPropagation()
if (!isExpanded) {
expandToLastHeight()
}
if (showInput) setShowInput(false)
}}
onClick={handleOutputButtonClick}
aria-label='Show output'
>
Output
@@ -277,13 +348,7 @@ export const OutputPanel = React.memo(function OutputPanel({
'px-[8px] py-[6px] text-[12px]',
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={(e) => {
e.stopPropagation()
if (!isExpanded) {
expandToLastHeight()
}
setShowInput(true)
}}
onClick={handleInputButtonClick}
aria-label='Show input'
>
Input
@@ -291,16 +356,29 @@ export const OutputPanel = React.memo(function OutputPanel({
)}
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{/* Unified filter popover */}
{filteredEntries.length > 0 && (
<FilterPopover
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
toggleStatus={toggleStatus}
toggleBlock={toggleBlock}
toggleRunId={toggleRunId}
uniqueBlocks={uniqueBlocks}
uniqueRunIds={uniqueRunIds}
executionColorMap={executionColorMap}
hasActiveFilters={hasActiveFilters}
/>
)}
{isOutputSearchActive ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
closeOutputSearch()
}}
aria-label='Search in output'
onClick={handleCloseSearchClick}
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-[12px] w-[12px]' />
@@ -315,10 +393,7 @@ export const OutputPanel = React.memo(function OutputPanel({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
activateOutputSearch()
}}
onClick={handleSearchClick}
aria-label='Search in output'
className='!p-1.5 -m-1.5'
>
@@ -379,10 +454,7 @@ export const OutputPanel = React.memo(function OutputPanel({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
onClick={handleCopyClick}
aria-label='Copy output'
className='!p-1.5 -m-1.5'
>
@@ -419,10 +491,7 @@ export const OutputPanel = React.memo(function OutputPanel({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
clearFilters()
}}
onClick={handleClearFiltersClick}
aria-label='Clear filters'
className='!p-1.5 -m-1.5'
>
@@ -455,9 +524,7 @@ export const OutputPanel = React.memo(function OutputPanel({
<PopoverTrigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
}}
onClick={(e) => e.stopPropagation()}
aria-label='Terminal options'
className='!p-1.5 -m-1.5'
>
@@ -476,42 +543,23 @@ export const OutputPanel = React.memo(function OutputPanel({
<PopoverItem
active={structuredView}
showCheck={structuredView}
onClick={(e) => {
e.stopPropagation()
setStructuredView(!structuredView)
}}
onClick={handleToggleStructuredView}
>
<span>Structured view</span>
</PopoverItem>
<PopoverItem
active={wrapText}
showCheck={wrapText}
onClick={(e) => {
e.stopPropagation()
setWrapText(!wrapText)
}}
>
<PopoverItem active={wrapText} showCheck={wrapText} onClick={handleToggleWrapText}>
<span>Wrap text</span>
</PopoverItem>
<PopoverItem
active={openOnRun}
showCheck={openOnRun}
onClick={(e) => {
e.stopPropagation()
setOpenOnRun(!openOnRun)
}}
onClick={handleToggleOpenOnRun}
>
<span>Open on run</span>
</PopoverItem>
</PopoverContent>
</Popover>
<ToggleButton
isExpanded={isExpanded}
onClick={(e) => {
e.stopPropagation()
handleHeaderClick()
}}
/>
<ToggleButton isExpanded={isExpanded} onClick={handleToggleButtonClick} />
</div>
</div>
@@ -578,7 +626,7 @@ export const OutputPanel = React.memo(function OutputPanel({
code={selectedEntry.input.code}
language={(selectedEntry.input.language as 'javascript' | 'json') || 'javascript'}
wrapText={wrapText}
searchQuery={isOutputSearchActive ? outputSearchQuery : undefined}
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
@@ -588,8 +636,9 @@ export const OutputPanel = React.memo(function OutputPanel({
data={outputData}
wrapText={wrapText}
isError={!showInput && Boolean(selectedEntry.error)}
isRunning={!showInput && Boolean(selectedEntry.isRunning)}
className='min-h-full'
searchQuery={isOutputSearchActive ? outputSearchQuery : undefined}
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
@@ -599,7 +648,7 @@ export const OutputPanel = React.memo(function OutputPanel({
code={outputDataStringified}
language='json'
wrapText={wrapText}
searchQuery={isOutputSearchActive ? outputSearchQuery : undefined}
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
@@ -618,11 +667,11 @@ export const OutputPanel = React.memo(function OutputPanel({
onCopyAll={handleCopy}
onSearch={activateOutputSearch}
structuredView={structuredView}
onToggleStructuredView={() => setStructuredView(!structuredView)}
onToggleStructuredView={handleToggleStructuredView}
wrapText={wrapText}
onToggleWrap={() => setWrapText(!wrapText)}
onToggleWrap={handleToggleWrapText}
openOnRun={openOnRun}
onToggleOpenOnRun={() => setOpenOnRun(!openOnRun)}
onToggleOpenOnRun={handleToggleOpenOnRun}
onClearConsole={handleClearConsoleFromMenu}
hasSelection={hasSelection}
/>

View File

@@ -0,0 +1 @@
export { ToggleButton, type ToggleButtonProps } from './toggle-button'

View File

@@ -0,0 +1,33 @@
'use client'
import type React from 'react'
import { memo } from 'react'
import clsx from 'clsx'
import { ChevronDown } from 'lucide-react'
import { Button } from '@/components/emcn'
export interface ToggleButtonProps {
isExpanded: boolean
onClick: (e: React.MouseEvent) => void
}
/**
* Toggle button component for terminal expand/collapse
*/
export const ToggleButton = memo(function ToggleButton({ isExpanded, onClick }: ToggleButtonProps) {
return (
<Button
variant='ghost'
className='!p-1.5 -m-1.5'
onClick={onClick}
aria-label='Toggle terminal'
>
<ChevronDown
className={clsx(
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
!isExpanded && 'rotate-180'
)}
/>
</Button>
)
})

View File

@@ -1,3 +1,4 @@
export type { SortConfig, SortDirection, SortField, TerminalFilters } from '../types'
export { useOutputPanelResize } from './use-output-panel-resize'
export { useTerminalFilters } from './use-terminal-filters'
export { useTerminalResize } from './use-terminal-resize'

View File

@@ -1,26 +1,10 @@
import { useCallback, useMemo, useState } from 'react'
import type {
SortConfig,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import type { ConsoleEntry } from '@/stores/terminal'
/**
* Sort configuration
*/
export type SortField = 'timestamp'
export type SortDirection = 'asc' | 'desc'
export interface SortConfig {
field: SortField
direction: SortDirection
}
/**
* Filter configuration state
*/
export interface TerminalFilters {
blockIds: Set<string>
statuses: Set<'error' | 'info'>
runIds: Set<string>
}
/**
* Custom hook to manage terminal filters and sorting.
* Provides filter state, sort state, and filtering/sorting logic for console entries.

View File

@@ -0,0 +1,111 @@
'use client'
import { memo } from 'react'
import { Badge } from '@/components/emcn'
/**
* Terminal filter configuration state
*/
export interface TerminalFilters {
blockIds: Set<string>
statuses: Set<'error' | 'info'>
runIds: Set<string>
}
/**
* Context menu position for positioning floating menus
*/
export interface ContextMenuPosition {
x: number
y: number
}
/**
* Sort field options for terminal entries
*/
export type SortField = 'timestamp'
/**
* Sort direction options
*/
export type SortDirection = 'asc' | 'desc'
/**
* Sort configuration for terminal entries
*/
export interface SortConfig {
field: SortField
direction: SortDirection
}
/**
* Status type for console entries
*/
export type EntryStatus = 'error' | 'info'
/**
* Block information for filters
*/
export interface BlockInfo {
blockId: string
blockName: string
blockType: string
}
/**
* Common row styling classes for terminal components
*/
export const ROW_STYLES = {
base: 'group flex cursor-pointer items-center justify-between gap-[8px] rounded-[8px] px-[6px]',
selected: 'bg-[var(--surface-6)] dark:bg-[var(--surface-5)]',
hover: 'hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
nested:
'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]',
iconButton: '!p-1.5 -m-1.5',
} as const
/**
* Common badge styling for status badges
*/
export const BADGE_STYLES = {
base: 'rounded-[4px] px-[4px] py-[0px] text-[11px]',
mono: 'rounded-[4px] px-[4px] py-[0px] font-mono text-[11px]',
} as const
/**
* Running badge component - displays a consistent "Running" indicator
*/
export const RunningBadge = memo(function RunningBadge() {
return (
<Badge variant='green' className={BADGE_STYLES.base}>
Running
</Badge>
)
})
/**
* Props for StatusDisplay component
*/
export interface StatusDisplayProps {
isRunning: boolean
isCanceled: boolean
formattedDuration: string
}
/**
* Reusable status display for terminal rows.
* Shows Running badge, 'canceled' text, or formatted duration.
*/
export const StatusDisplay = memo(function StatusDisplay({
isRunning,
isCanceled,
formattedDuration,
}: StatusDisplayProps) {
if (isRunning) {
return <RunningBadge />
}
if (isCanceled) {
return <>canceled</>
}
return <>{formattedDuration}</>
})

View File

@@ -0,0 +1,488 @@
'use client'
import type React from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { getBlock } from '@/blocks'
import type { ConsoleEntry } from '@/stores/terminal'
/**
* Subflow colors matching the subflow tool configs
*/
const SUBFLOW_COLORS = {
loop: '#2FB3FF',
parallel: '#FEE12B',
} as const
/**
* Run ID color palette for visual distinction between executions
*/
export const RUN_ID_COLORS = [
'#4ADE80', // Green
'#F472B6', // Pink
'#60C5FF', // Blue
'#FF8533', // Orange
'#C084FC', // Purple
'#EAB308', // Yellow
'#2DD4BF', // Teal
'#FB7185', // Rose
] as const
/**
* Retrieves the icon component for a given block type
*/
export function getBlockIcon(
blockType: string
): React.ComponentType<{ className?: string }> | null {
const blockConfig = getBlock(blockType)
if (blockConfig?.icon) {
return blockConfig.icon
}
if (blockType === 'loop') {
return RepeatIcon
}
if (blockType === 'parallel') {
return SplitIcon
}
return null
}
/**
* Gets the background color for a block type
*/
export function getBlockColor(blockType: string): string {
const blockConfig = getBlock(blockType)
if (blockConfig?.bgColor) {
return blockConfig.bgColor
}
// Use proper subflow colors matching the toolbar configs
if (blockType === 'loop') {
return SUBFLOW_COLORS.loop
}
if (blockType === 'parallel') {
return SUBFLOW_COLORS.parallel
}
return '#6b7280'
}
/**
* Formats duration from milliseconds to readable format
*/
export function formatDuration(ms?: number): string {
if (ms === undefined || ms === null) return '-'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Truncates execution ID for display as run ID
*/
export function formatRunId(executionId?: string): string {
if (!executionId) return '-'
return executionId.slice(0, 8)
}
/**
* Gets color for a run ID from the precomputed color map
*/
export function getRunIdColor(
executionId: string | undefined,
colorMap: Map<string, string>
): string | null {
if (!executionId) return null
return colorMap.get(executionId) ?? null
}
/**
* Determines if a keyboard event originated from a text-editable element
*/
export function isEventFromEditableElement(e: KeyboardEvent): boolean {
const target = e.target as HTMLElement | null
if (!target) return false
const isEditable = (el: HTMLElement | null): boolean => {
if (!el) return false
if (el instanceof HTMLInputElement) return true
if (el instanceof HTMLTextAreaElement) return true
if ((el as HTMLElement).isContentEditable) return true
const role = el.getAttribute('role')
if (role === 'textbox' || role === 'combobox') return true
return false
}
let el: HTMLElement | null = target
while (el) {
if (isEditable(el)) return true
el = el.parentElement
}
return false
}
/**
* Checks if a block type is a subflow (loop or parallel)
*/
export function isSubflowBlockType(blockType: string): boolean {
const lower = blockType?.toLowerCase() || ''
return lower === 'loop' || lower === 'parallel'
}
/**
* Node type for the tree structure
*/
export type EntryNodeType = 'block' | 'subflow' | 'iteration'
/**
* Entry node for tree structure - represents a block, subflow, or iteration
*/
export interface EntryNode {
/** The console entry (for blocks) or synthetic entry (for subflows/iterations) */
entry: ConsoleEntry
/** Child nodes */
children: EntryNode[]
/** Node type */
nodeType: EntryNodeType
/** Iteration info for iteration nodes */
iterationInfo?: {
current: number
total?: number
}
}
/**
* Execution group interface for grouping entries by execution
*/
export interface ExecutionGroup {
executionId: string
startTime: string
endTime: string
startTimeMs: number
endTimeMs: number
duration: number
status: 'success' | 'error'
/** Flat list of entries (legacy, kept for filters) */
entries: ConsoleEntry[]
/** Tree structure of entry nodes for nested display */
entryTree: EntryNode[]
}
/**
* Iteration group for grouping blocks within the same iteration
*/
interface IterationGroup {
iterationType: string
iterationCurrent: number
iterationTotal?: number
blocks: ConsoleEntry[]
startTimeMs: number
}
/**
* Builds a tree structure from flat entries.
* Groups iteration entries by (iterationType, iterationCurrent), showing all blocks
* that executed within each iteration.
* Sorts by start time to ensure chronological order.
*/
function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
// Separate regular blocks from iteration entries
const regularBlocks: ConsoleEntry[] = []
const iterationEntries: ConsoleEntry[] = []
for (const entry of entries) {
if (entry.iterationType && entry.iterationCurrent !== undefined) {
iterationEntries.push(entry)
} else {
regularBlocks.push(entry)
}
}
// Group iteration entries by (iterationType, iterationCurrent)
const iterationGroupsMap = new Map<string, IterationGroup>()
for (const entry of iterationEntries) {
const key = `${entry.iterationType}-${entry.iterationCurrent}`
let group = iterationGroupsMap.get(key)
const entryStartMs = new Date(entry.startedAt || entry.timestamp).getTime()
if (!group) {
group = {
iterationType: entry.iterationType!,
iterationCurrent: entry.iterationCurrent!,
iterationTotal: entry.iterationTotal,
blocks: [],
startTimeMs: entryStartMs,
}
iterationGroupsMap.set(key, group)
} else {
// Update start time to earliest
if (entryStartMs < group.startTimeMs) {
group.startTimeMs = entryStartMs
}
// Update total if available
if (entry.iterationTotal !== undefined) {
group.iterationTotal = entry.iterationTotal
}
}
group.blocks.push(entry)
}
// Sort blocks within each iteration by start time ascending (oldest first, top-down)
for (const group of iterationGroupsMap.values()) {
group.blocks.sort((a, b) => {
const aStart = new Date(a.startedAt || a.timestamp).getTime()
const bStart = new Date(b.startedAt || b.timestamp).getTime()
return aStart - bStart
})
}
// Group iterations by iterationType to create subflow parents
const subflowGroups = new Map<string, IterationGroup[]>()
for (const group of iterationGroupsMap.values()) {
const type = group.iterationType
let groups = subflowGroups.get(type)
if (!groups) {
groups = []
subflowGroups.set(type, groups)
}
groups.push(group)
}
// Sort iterations within each subflow by iteration number
for (const groups of subflowGroups.values()) {
groups.sort((a, b) => a.iterationCurrent - b.iterationCurrent)
}
// Build subflow nodes with iteration children
const subflowNodes: EntryNode[] = []
for (const [iterationType, iterationGroups] of subflowGroups.entries()) {
// Calculate subflow timing from all its iterations
const firstIteration = iterationGroups[0]
const allBlocks = iterationGroups.flatMap((g) => g.blocks)
const subflowStartMs = Math.min(
...allBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime())
)
const subflowEndMs = Math.max(
...allBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
)
const totalDuration = allBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
// Create synthetic subflow parent entry
const syntheticSubflow: ConsoleEntry = {
id: `subflow-${iterationType}-${firstIteration.blocks[0]?.executionId || 'unknown'}`,
timestamp: new Date(subflowStartMs).toISOString(),
workflowId: firstIteration.blocks[0]?.workflowId || '',
blockId: `${iterationType}-container`,
blockName: iterationType.charAt(0).toUpperCase() + iterationType.slice(1),
blockType: iterationType,
executionId: firstIteration.blocks[0]?.executionId,
startedAt: new Date(subflowStartMs).toISOString(),
endedAt: new Date(subflowEndMs).toISOString(),
durationMs: totalDuration,
success: !allBlocks.some((b) => b.error),
}
// Build iteration child nodes
const iterationNodes: EntryNode[] = iterationGroups.map((iterGroup) => {
// Create synthetic iteration entry
const iterBlocks = iterGroup.blocks
const iterStartMs = Math.min(
...iterBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime())
)
const iterEndMs = Math.max(
...iterBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
)
const iterDuration = iterBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
const syntheticIteration: ConsoleEntry = {
id: `iteration-${iterationType}-${iterGroup.iterationCurrent}-${iterBlocks[0]?.executionId || 'unknown'}`,
timestamp: new Date(iterStartMs).toISOString(),
workflowId: iterBlocks[0]?.workflowId || '',
blockId: `iteration-${iterGroup.iterationCurrent}`,
blockName: `Iteration ${iterGroup.iterationCurrent}${iterGroup.iterationTotal !== undefined ? ` / ${iterGroup.iterationTotal}` : ''}`,
blockType: iterationType,
executionId: iterBlocks[0]?.executionId,
startedAt: new Date(iterStartMs).toISOString(),
endedAt: new Date(iterEndMs).toISOString(),
durationMs: iterDuration,
success: !iterBlocks.some((b) => b.error),
iterationCurrent: iterGroup.iterationCurrent,
iterationTotal: iterGroup.iterationTotal,
iterationType: iterationType as 'loop' | 'parallel',
}
// Block nodes within this iteration
const blockNodes: EntryNode[] = iterBlocks.map((block) => ({
entry: block,
children: [],
nodeType: 'block' as const,
}))
return {
entry: syntheticIteration,
children: blockNodes,
nodeType: 'iteration' as const,
iterationInfo: {
current: iterGroup.iterationCurrent,
total: iterGroup.iterationTotal,
},
}
})
subflowNodes.push({
entry: syntheticSubflow,
children: iterationNodes,
nodeType: 'subflow' as const,
})
}
// Build nodes for regular blocks
const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({
entry,
children: [],
nodeType: 'block' as const,
}))
// Combine all nodes and sort by start time ascending (oldest first, top-down)
const allNodes = [...subflowNodes, ...regularNodes]
allNodes.sort((a, b) => {
const aStart = new Date(a.entry.startedAt || a.entry.timestamp).getTime()
const bStart = new Date(b.entry.startedAt || b.entry.timestamp).getTime()
return aStart - bStart
})
return allNodes
}
/**
* Groups console entries by execution ID and builds a tree structure.
* Pre-computes timestamps for efficient sorting.
*/
export function groupEntriesByExecution(entries: ConsoleEntry[]): ExecutionGroup[] {
const groups = new Map<
string,
{ meta: Omit<ExecutionGroup, 'entryTree'>; entries: ConsoleEntry[] }
>()
for (const entry of entries) {
const execId = entry.executionId || entry.id
const entryStartTime = entry.startedAt || entry.timestamp
const entryEndTime = entry.endedAt || entry.timestamp
const entryStartMs = new Date(entryStartTime).getTime()
const entryEndMs = new Date(entryEndTime).getTime()
let group = groups.get(execId)
if (!group) {
group = {
meta: {
executionId: execId,
startTime: entryStartTime,
endTime: entryEndTime,
startTimeMs: entryStartMs,
endTimeMs: entryEndMs,
duration: 0,
status: 'success',
entries: [],
},
entries: [],
}
groups.set(execId, group)
} else {
// Update timing bounds
if (entryStartMs < group.meta.startTimeMs) {
group.meta.startTime = entryStartTime
group.meta.startTimeMs = entryStartMs
}
if (entryEndMs > group.meta.endTimeMs) {
group.meta.endTime = entryEndTime
group.meta.endTimeMs = entryEndMs
}
}
// Check for errors
if (entry.error) {
group.meta.status = 'error'
}
group.entries.push(entry)
}
// Build tree structure for each group
const result: ExecutionGroup[] = []
for (const group of groups.values()) {
group.meta.duration = group.meta.endTimeMs - group.meta.startTimeMs
group.meta.entries = group.entries
result.push({
...group.meta,
entryTree: buildEntryTree(group.entries),
})
}
// Sort by start time descending (newest first)
result.sort((a, b) => b.startTimeMs - a.startTimeMs)
return result
}
/**
* Flattens entry tree into display order for keyboard navigation
*/
export function flattenEntryTree(nodes: EntryNode[]): ConsoleEntry[] {
const result: ConsoleEntry[] = []
for (const node of nodes) {
result.push(node.entry)
if (node.children.length > 0) {
result.push(...flattenEntryTree(node.children))
}
}
return result
}
/**
* Block entry with parent tracking for navigation
*/
export interface NavigableBlockEntry {
entry: ConsoleEntry
executionId: string
/** IDs of parent nodes (subflows, iterations) that contain this block */
parentNodeIds: string[]
}
/**
* Flattens entry tree to only include actual block entries (not subflows/iterations).
* Also tracks parent node IDs for auto-expanding when navigating.
*/
export function flattenBlockEntriesOnly(
nodes: EntryNode[],
executionId: string,
parentIds: string[] = []
): NavigableBlockEntry[] {
const result: NavigableBlockEntry[] = []
for (const node of nodes) {
if (node.nodeType === 'block') {
result.push({
entry: node.entry,
executionId,
parentNodeIds: parentIds,
})
}
if (node.children.length > 0) {
const newParentIds = node.nodeType !== 'block' ? [...parentIds, node.entry.id] : parentIds
result.push(...flattenBlockEntriesOnly(node.children, executionId, newParentIds))
}
}
return result
}
// BlockInfo is now in types.ts for shared use across terminal components
/**
* Terminal height configuration constants
*/
export const TERMINAL_CONFIG = {
NEAR_MIN_THRESHOLD: 40,
BLOCK_COLUMN_WIDTH_PX: 240,
HEADER_TEXT_CLASS: 'font-medium text-[var(--text-tertiary)] text-[12px]',
} as const

View File

@@ -81,7 +81,8 @@ export function useWorkflowExecution() {
const queryClient = useQueryClient()
const currentWorkflow = useCurrentWorkflow()
const { activeWorkflowId, workflows } = useWorkflowRegistry()
const { toggleConsole, addConsole } = useTerminalConsoleStore()
const { toggleConsole, addConsole, updateConsole, cancelRunningEntries } =
useTerminalConsoleStore()
const { getAllVariables } = useEnvironmentStore()
const { getVariablesByWorkflowId, variables } = useVariablesStore()
const {
@@ -867,6 +868,8 @@ export function useWorkflowExecution() {
if (activeWorkflowId) {
logger.info('Using server-side executor')
const executionId = uuidv4()
let executionResult: ExecutionResult = {
success: false,
output: {},
@@ -910,6 +913,27 @@ export function useWorkflowExecution() {
incomingEdges.forEach((edge) => {
setEdgeRunStatus(edge.id, 'success')
})
// Add entry to terminal immediately with isRunning=true
const startedAt = new Date().toISOString()
addConsole({
input: {},
output: undefined,
success: undefined,
durationMs: undefined,
startedAt,
endedAt: undefined,
workflowId: activeWorkflowId,
blockId: data.blockId,
executionId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
isRunning: true,
// Pass through iteration context for subflow grouping
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
})
},
onBlockCompleted: (data) => {
@@ -940,24 +964,23 @@ export function useWorkflowExecution() {
endedAt,
})
// Add to console
addConsole({
input: data.input || {},
output: data.output,
success: true,
durationMs: data.durationMs,
startedAt,
endedAt,
workflowId: activeWorkflowId,
blockId: data.blockId,
executionId: executionId || uuidv4(),
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
// Pass through iteration context for console pills
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
})
// Update existing console entry (created in onBlockStarted) with completion data
updateConsole(
data.blockId,
{
input: data.input || {},
replaceOutput: data.output,
success: true,
durationMs: data.durationMs,
endedAt,
isRunning: false,
// Pass through iteration context for subflow grouping
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
},
executionId
)
// Call onBlockComplete callback if provided
if (onBlockComplete) {
@@ -992,25 +1015,24 @@ export function useWorkflowExecution() {
endedAt,
})
// Add error to console
addConsole({
input: data.input || {},
output: {},
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt,
endedAt,
workflowId: activeWorkflowId,
blockId: data.blockId,
executionId: executionId || uuidv4(),
blockName: data.blockName,
blockType: data.blockType,
// Pass through iteration context for console pills
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
})
// Update existing console entry (created in onBlockStarted) with error data
updateConsole(
data.blockId,
{
input: data.input || {},
replaceOutput: {},
success: false,
error: data.error,
durationMs: data.durationMs,
endedAt,
isRunning: false,
// Pass through iteration context for subflow grouping
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
},
executionId
)
},
onStreamChunk: (data) => {
@@ -1089,7 +1111,7 @@ export function useWorkflowExecution() {
endedAt: new Date().toISOString(),
workflowId: activeWorkflowId,
blockId: 'validation',
executionId: executionId || uuidv4(),
executionId,
blockName: 'Workflow Validation',
blockType: 'validation',
})
@@ -1358,6 +1380,11 @@ export function useWorkflowExecution() {
// Mark current chat execution as superseded so its cleanup won't affect new executions
currentChatExecutionIdRef.current = null
// Mark all running entries as canceled in the terminal
if (activeWorkflowId) {
cancelRunningEntries(activeWorkflowId)
}
// Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx
setIsExecuting(false)
setIsDebugging(false)
@@ -1374,6 +1401,8 @@ export function useWorkflowExecution() {
setIsExecuting,
setIsDebugging,
setActiveBlocks,
activeWorkflowId,
cancelRunningEntries,
])
return {

View File

@@ -949,6 +949,9 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
[contentRef]
)
const hasCollapsibleContent = collapsibleLines.size > 0
const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent
const rowProps = useMemo(
() => ({
lines: visibleLines,
@@ -957,7 +960,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
gutterStyle,
leftOffset: paddingLeft,
wrapText,
showCollapseColumn,
showCollapseColumn: effectiveShowCollapseColumn,
collapsibleLines,
collapsedLines,
onToggleCollapse: toggleCollapse,
@@ -969,7 +972,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
gutterStyle,
paddingLeft,
wrapText,
showCollapseColumn,
effectiveShowCollapseColumn,
collapsibleLines,
collapsedLines,
toggleCollapse,
@@ -1103,7 +1106,10 @@ function ViewerInner({
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex])
const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
const collapseColumnWidth = showCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0
const hasCollapsibleContent = collapsibleLines.size > 0
const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent
const collapseColumnWidth = effectiveShowCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0
// Grid-based rendering for gutter alignment (works with wrap)
if (showGutter) {
@@ -1116,7 +1122,7 @@ function ViewerInner({
paddingTop: '8px',
paddingBottom: '8px',
display: 'grid',
gridTemplateColumns: showCollapseColumn
gridTemplateColumns: effectiveShowCollapseColumn
? `${gutterWidth}px ${collapseColumnWidth}px 1fr`
: `${gutterWidth}px 1fr`,
}}
@@ -1134,7 +1140,7 @@ function ViewerInner({
>
{lineNumber}
</div>
{showCollapseColumn && (
{effectiveShowCollapseColumn && (
<div className='ml-1 flex items-start justify-end'>
{isCollapsible && (
<CollapseButton

View File

@@ -339,12 +339,49 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
: update.input
}
if (update.isRunning !== undefined) {
updatedEntry.isRunning = update.isRunning
}
if (update.isCanceled !== undefined) {
updatedEntry.isCanceled = update.isCanceled
}
if (update.iterationCurrent !== undefined) {
updatedEntry.iterationCurrent = update.iterationCurrent
}
if (update.iterationTotal !== undefined) {
updatedEntry.iterationTotal = update.iterationTotal
}
if (update.iterationType !== undefined) {
updatedEntry.iterationType = update.iterationType
}
return updatedEntry
})
return { entries: updatedEntries }
})
},
cancelRunningEntries: (workflowId: string) => {
set((state) => {
const updatedEntries = state.entries.map((entry) => {
if (entry.workflowId === workflowId && entry.isRunning) {
return {
...entry,
isRunning: false,
isCanceled: true,
endedAt: new Date().toISOString(),
}
}
return entry
})
return { entries: updatedEntries }
})
},
}),
{
name: 'terminal-console-store',

View File

@@ -20,6 +20,10 @@ export interface ConsoleEntry {
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
/** Whether this block is currently running */
isRunning?: boolean
/** Whether this block execution was canceled */
isCanceled?: boolean
}
export interface ConsoleUpdate {
@@ -32,6 +36,14 @@ export interface ConsoleUpdate {
endedAt?: string
durationMs?: number
input?: any
/** Whether this block is currently running */
isRunning?: boolean
/** Whether this block execution was canceled */
isCanceled?: boolean
/** Iteration context for subflow blocks */
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
}
export interface ConsoleStore {
@@ -43,6 +55,7 @@ export interface ConsoleStore {
getWorkflowEntries: (workflowId: string) => ConsoleEntry[]
toggleConsole: () => void
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
cancelRunningEntries: (workflowId: string) => void
_hasHydrated: boolean
setHasHydrated: (hasHydrated: boolean) => void
}