mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-28 16:27:55 -05:00
feat(terminal): log view
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { FilterPopover, type FilterPopoverProps } from './filter-popover'
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ToggleButton, type ToggleButtonProps } from './toggle-button'
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}</>
|
||||
})
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user