Compare commits

...

5 Commits

Author SHA1 Message Date
Emir Karabeg
2e29dbb318 improvement(terminal): ui/ux 2026-01-28 02:01:46 -08:00
Emir Karabeg
07be51e7a1 feat(terminal): log view 2026-01-28 00:53:45 -08:00
Emir Karabeg
eaa658b16e feat(terminal): added structured output; improvement(preview): note block 2026-01-27 16:48:41 -08:00
Emir Karabeg
d8d4a6168c improvement(code): addressed comments 2026-01-27 11:47:08 -08:00
Emir Karabeg
efefc16199 feat(code): collapsed JSON in terminal 2026-01-27 11:08:28 -08:00
31 changed files with 3531 additions and 1703 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,121 @@
'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 { getBlockIcon } 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
uniqueBlocks: BlockInfo[]
hasActiveFilters: boolean
}
/**
* Filter popover component used in terminal header and output panel
*/
export const FilterPopover = memo(function FilterPopover({
open,
onOpenChange,
filters,
toggleStatus,
toggleBlock,
uniqueBlocks,
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='top'
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 className='my-[4px]' />
<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>
</>
)}
</PopoverContent>
</Popover>
)
})

View File

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

View File

@@ -1,2 +1,5 @@
export { LogRowContextMenu } from './log-row-context-menu'
export { OutputContextMenu } from './output-context-menu'
export { FilterPopover, type FilterPopoverProps } from './filter-popover'
export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu'
export { OutputPanel, type OutputPanelProps } from './output-panel'
export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display'
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>
@@ -30,19 +23,16 @@ interface LogRowContextMenuProps {
filters: TerminalFilters
onFilterByBlock: (blockId: string) => void
onFilterByStatus: (status: 'error' | 'info') => void
onFilterByRunId: (runId: string) => void
onCopyRunId: (runId: string) => void
onClearFilters: () => void
onClearConsole: () => void
onFixInCopilot: (entry: ConsoleEntry) => void
hasActiveFilters: boolean
}
/**
* 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,
@@ -51,19 +41,15 @@ export function LogRowContextMenu({
filters,
onFilterByBlock,
onFilterByStatus,
onFilterByRunId,
onCopyRunId,
onClearFilters,
onClearConsole,
onFixInCopilot,
hasActiveFilters,
}: LogRowContextMenuProps) {
const hasRunId = entry?.executionId != null
const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false
const entryStatus = entry?.success ? 'info' : 'error'
const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false
const isRunIdFiltered = entry?.executionId ? filters.runIds.has(entry.executionId) : false
return (
<Popover
@@ -134,34 +120,11 @@ export function LogRowContextMenu({
>
Filter by Status
</PopoverItem>
{hasRunId && (
<PopoverItem
showCheck={isRunIdFiltered}
onClick={() => {
onFilterByRunId(entry.executionId!)
onClose()
}}
>
Filter by Run ID
</PopoverItem>
)}
</>
)}
{/* Clear filters */}
{hasActiveFilters && (
<PopoverItem
onClick={() => {
onClearFilters()
onClose()
}}
>
Clear All Filters
</PopoverItem>
)}
{/* Destructive action */}
{(entry || hasActiveFilters) && <PopoverDivider />}
{entry && <PopoverDivider />}
<PopoverItem
onClick={() => {
onClearConsole()
@@ -173,4 +136,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>
@@ -22,6 +18,8 @@ interface OutputContextMenuProps {
onCopySelection: () => void
onCopyAll: () => void
onSearch: () => void
structuredView: boolean
onToggleStructuredView: () => void
wrapText: boolean
onToggleWrap: () => void
openOnRun: boolean
@@ -34,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,
@@ -42,6 +40,8 @@ export function OutputContextMenu({
onCopySelection,
onCopyAll,
onSearch,
structuredView,
onToggleStructuredView,
wrapText,
onToggleWrap,
openOnRun,
@@ -96,6 +96,9 @@ export function OutputContextMenu({
{/* Display settings - toggles don't close menu */}
<PopoverDivider />
<PopoverItem showCheck={structuredView} onClick={onToggleStructuredView}>
Structured View
</PopoverItem>
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
Wrap Text
</PopoverItem>
@@ -116,4 +119,4 @@ export function OutputContextMenu({
</PopoverContent>
</Popover>
)
}
})

View File

@@ -0,0 +1,609 @@
'use client'
import type React from 'react'
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'
type BadgeVariant = 'green' | 'blue' | 'orange' | 'purple' | 'gray' | 'red'
interface NodeEntry {
key: string
value: unknown
path: string
}
/**
* 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
pathToMatchIndices: Map<string, number[]>
currentMatchIndexRef: React.RefObject<number>
}
const SearchContext = createContext<SearchContextValue | null>(null)
const BADGE_VARIANTS: Record<ValueType, BadgeVariant> = {
string: 'green',
number: 'blue',
boolean: 'orange',
array: 'purple',
null: 'gray',
undefined: 'gray',
object: 'gray',
} as const
const STYLES = {
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-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
const EMPTY_MATCH_INDICES: number[] = []
/**
* Returns the type label for a value
*/
function getTypeLabel(value: unknown): ValueType {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (Array.isArray(value)) return 'array'
return typeof value as ValueType
}
/**
* Formats a primitive value for display
*/
function formatPrimitive(value: unknown): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
return String(value)
}
/**
* Checks if a value is a primitive (not object/array)
*/
function isPrimitive(value: unknown): value is null | undefined | string | number | boolean {
return value === null || value === undefined || typeof value !== 'object'
}
/**
* Checks if a value is an empty object or array
*/
function isEmpty(value: unknown): boolean {
if (Array.isArray(value)) return value.length === 0
if (typeof value === 'object' && value !== null) return Object.keys(value).length === 0
return false
}
/**
* Extracts error message from various error data formats
*/
function extractErrorMessage(data: unknown): string {
if (typeof data === 'string') return data
if (data instanceof Error) return data.message
if (typeof data === 'object' && data !== null && 'message' in data) {
return String((data as { message: unknown }).message)
}
return JSON.stringify(data, null, 2)
}
/**
* Builds node entries from an object or array value
*/
function buildEntries(value: unknown, basePath: string): NodeEntry[] {
if (Array.isArray(value)) {
return value.map((item, i) => ({ key: String(i), value: item, path: `${basePath}[${i}]` }))
}
return Object.entries(value as Record<string, unknown>).map(([k, v]) => ({
key: k,
value: v,
path: `${basePath}.${k}`,
}))
}
/**
* Gets the count summary for collapsed arrays/objects
*/
function getCollapsedSummary(value: unknown): string | null {
if (Array.isArray(value)) {
const len = value.length
return `${len} item${len !== 1 ? 's' : ''}`
}
if (typeof value === 'object' && value !== null) {
const count = Object.keys(value).length
return `${count} key${count !== 1 ? 's' : ''}`
}
return null
}
/**
* Computes initial expanded paths for first-level items
*/
function computeInitialPaths(data: unknown, isError: boolean): Set<string> {
if (isError) return new Set(['root.error'])
if (!data || typeof data !== 'object') return new Set()
const entries = Array.isArray(data)
? data.map((_, i) => `root[${i}]`)
: Object.keys(data).map((k) => `root.${k}`)
return new Set(entries)
}
/**
* Gets all ancestor paths needed to reach a given path
*/
function getAncestorPaths(path: string): string[] {
const ancestors: string[] = []
let current = path
while (current.includes('.') || current.includes('[')) {
const splitPoint = Math.max(current.lastIndexOf('.'), current.lastIndexOf('['))
if (splitPoint <= 0) break
current = current.slice(0, splitPoint)
if (current !== 'root') ancestors.push(current)
}
return ancestors
}
/**
* Finds all case-insensitive matches of a query within text
*/
function findTextMatches(text: string, query: string): Array<[number, number]> {
if (!query) return []
const matches: Array<[number, number]> = []
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
let pos = 0
while (pos < lowerText.length) {
const idx = lowerText.indexOf(lowerQuery, pos)
if (idx === -1) break
matches.push([idx, idx + query.length])
pos = idx + 1
}
return matches
}
/**
* Adds match entries for a primitive value at the given path
*/
function addPrimitiveMatches(value: unknown, path: string, query: string, matches: string[]): void {
const text = formatPrimitive(value)
const count = findTextMatches(text, query).length
for (let i = 0; i < count; i++) {
matches.push(path)
}
}
/**
* Recursively collects all match paths across the entire data tree
*/
function collectAllMatchPaths(data: unknown, query: string, basePath: string): string[] {
if (!query) return []
const matches: string[] = []
if (isPrimitive(data)) {
addPrimitiveMatches(data, `${basePath}.value`, query, matches)
return matches
}
for (const entry of buildEntries(data, basePath)) {
if (isPrimitive(entry.value)) {
addPrimitiveMatches(entry.value, entry.path, query, matches)
} else {
matches.push(...collectAllMatchPaths(entry.value, query, entry.path))
}
}
return matches
}
/**
* Builds a map from path to array of global match indices
*/
function buildPathToIndicesMap(matchPaths: string[]): Map<string, number[]> {
const map = new Map<string, number[]>()
matchPaths.forEach((path, globalIndex) => {
const existing = map.get(path)
if (existing) {
existing.push(globalIndex)
} else {
map.set(path, [globalIndex])
}
})
return map
}
interface HighlightedTextProps {
text: string
matchIndices: number[]
path: string
}
/**
* Renders text with search highlights.
* Uses context to access search state and avoid prop drilling.
*/
const HighlightedText = memo(function HighlightedText({
text,
matchIndices,
path,
}: HighlightedTextProps) {
const searchContext = useContext(SearchContext)
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
textMatches.forEach(([start, end], i) => {
const globalIndex = matchIndices[i]
const isCurrent = globalIndex === currentMatchIndex
if (start > lastEnd) {
segments.push(<span key={`t-${path}-${start}`}>{text.slice(lastEnd, start)}</span>)
}
segments.push(
<mark
key={`m-${path}-${start}`}
data-search-match
data-match-index={globalIndex}
className={cn(
'rounded-sm',
isCurrent ? STYLES.currentMatchHighlight : STYLES.matchHighlight
)}
>
{text.slice(start, end)}
</mark>
)
lastEnd = end
})
if (lastEnd < text.length) {
segments.push(<span key={`t-${path}-${lastEnd}`}>{text.slice(lastEnd)}</span>)
}
return <>{segments}</>
})
interface StructuredNodeProps {
name: string
value: unknown
path: string
expandedPaths: Set<string>
onToggle: (path: string) => void
wrapText: boolean
isError?: boolean
}
/**
* 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,
value,
path,
expandedPaths,
onToggle,
wrapText,
isError = false,
}: StructuredNodeProps) {
const searchContext = useContext(SearchContext)
const type = getTypeLabel(value)
const isPrimitiveValue = isPrimitive(value)
const isEmptyValue = !isPrimitiveValue && isEmpty(value)
const isExpanded = expandedPaths.has(path)
const handleToggle = useCallback(() => onToggle(path), [onToggle, path])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleToggle()
}
},
[handleToggle]
)
const childEntries = useMemo(
() => (isPrimitiveValue || isEmptyValue ? [] : buildEntries(value, path)),
[value, isPrimitiveValue, isEmptyValue, path]
)
const collapsedSummary = useMemo(
() => (isPrimitiveValue ? null : getCollapsedSummary(value)),
[value, isPrimitiveValue]
)
const badgeVariant = isError ? 'red' : BADGE_VARIANTS[type]
const valueText = isPrimitiveValue ? formatPrimitive(value) : ''
const matchIndices = searchContext?.pathToMatchIndices.get(path) ?? EMPTY_MATCH_INDICES
return (
<div className='flex min-w-0 flex-col'>
<div
className={STYLES.row}
onClick={handleToggle}
onKeyDown={handleKeyDown}
role='button'
tabIndex={0}
aria-expanded={isExpanded}
>
<span className={cn(STYLES.keyName, isError && 'text-[var(--text-error)]')}>{name}</span>
<Badge variant={badgeVariant} className={STYLES.badge}>
{type}
</Badge>
{!isExpanded && collapsedSummary && (
<span className={STYLES.summary}>{collapsedSummary}</span>
)}
<ChevronDown className={cn(STYLES.chevron, !isExpanded && '-rotate-90')} />
</div>
{isExpanded && (
<div className={STYLES.indent}>
{isPrimitiveValue ? (
<div
className={cn(
STYLES.value,
wrapText ? '[word-break:break-word]' : 'whitespace-nowrap'
)}
>
<HighlightedText text={valueText} matchIndices={matchIndices} path={path} />
</div>
) : isEmptyValue ? (
<div className={STYLES.emptyValue}>{Array.isArray(value) ? '[]' : '{}'}</div>
) : (
childEntries.map((entry) => (
<StructuredNode
key={entry.path}
name={entry.key}
value={entry.value}
path={entry.path}
expandedPaths={expandedPaths}
onToggle={onToggle}
wrapText={wrapText}
/>
))
)}
</div>
)}
</div>
)
})
export interface StructuredOutputProps {
data: unknown
wrapText?: boolean
isError?: boolean
isRunning?: boolean
className?: string
searchQuery?: string
currentMatchIndex?: number
onMatchCountChange?: (count: number) => void
contentRef?: React.RefObject<HTMLDivElement | null>
}
/**
* 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,
onMatchCountChange,
contentRef,
}: StructuredOutputProps) {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() =>
computeInitialPaths(data, isError)
)
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) => {
;(internalRef as React.MutableRefObject<HTMLDivElement | null>).current = node
if (contentRef) {
;(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node
}
},
[contentRef]
)
useEffect(() => {
if (prevDataRef.current !== data || prevIsErrorRef.current !== isError) {
prevDataRef.current = data
prevIsErrorRef.current = isError
setExpandedPaths(computeInitialPaths(data, isError))
}
}, [data, isError])
const allMatchPaths = useMemo(() => {
if (!searchQuery) return []
if (isError) {
const errorText = extractErrorMessage(data)
const count = findTextMatches(errorText, searchQuery).length
return Array(count).fill('root.error') as string[]
}
return collectAllMatchPaths(data, searchQuery, 'root')
}, [data, searchQuery, isError])
useEffect(() => {
onMatchCountChange?.(allMatchPaths.length)
}, [allMatchPaths.length, onMatchCountChange])
const pathToMatchIndices = useMemo(() => buildPathToIndicesMap(allMatchPaths), [allMatchPaths])
useEffect(() => {
if (
allMatchPaths.length === 0 ||
currentMatchIndex < 0 ||
currentMatchIndex >= allMatchPaths.length
) {
return
}
const currentPath = allMatchPaths[currentMatchIndex]
const pathsToExpand = [currentPath, ...getAncestorPaths(currentPath)]
setExpandedPaths((prev) => {
if (pathsToExpand.every((p) => prev.has(p))) return prev
const next = new Set(prev)
pathsToExpand.forEach((p) => next.add(p))
return next
})
}, [currentMatchIndex, allMatchPaths])
useEffect(() => {
if (allMatchPaths.length === 0) return
const rafId = requestAnimationFrame(() => {
const match = internalRef.current?.querySelector(
`[data-match-index="${currentMatchIndex}"]`
) as HTMLElement | null
match?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
return () => cancelAnimationFrame(rafId)
}, [currentMatchIndex, allMatchPaths.length, expandedPaths])
const handleToggle = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
}, [])
const rootEntries = useMemo<NodeEntry[]>(() => {
if (isPrimitive(data)) {
return [{ key: 'value', value: data, path: 'root.value' }]
}
return buildEntries(data, 'root')
}, [data])
// 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)
// Show "Running" badge when running with undefined data
if (isRunning && data === undefined) {
return (
<div ref={setContainerRef} className={containerClass}>
<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 (
<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

@@ -0,0 +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

@@ -0,0 +1,643 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
ArrowDown,
ArrowDownToLine,
ArrowUp,
Check,
Clipboard,
Database,
MoreHorizontal,
Palette,
Pause,
Search,
Trash2,
X,
} from 'lucide-react'
import Link from 'next/link'
import {
Button,
Code,
Input,
Popover,
PopoverContent,
PopoverItem,
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
language: 'javascript' | 'json'
wrapText: boolean
searchQuery: string | undefined
currentMatchIndex: number
onMatchCountChange: (count: number) => void
contentRef: React.RefObject<HTMLDivElement | null>
}
const OutputCodeContent = React.memo(function OutputCodeContent({
code,
language,
wrapText,
searchQuery,
currentMatchIndex,
onMatchCountChange,
contentRef,
}: OutputCodeContentProps) {
return (
<Code.Viewer
code={code}
showGutter
language={language}
className='m-0 min-h-full rounded-none border-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'
paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText}
searchQuery={searchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={onMatchCountChange}
contentRef={contentRef}
virtualized
showCollapseColumn={language === 'json'}
/>
)
})
/**
* 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
handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void
handleHeaderClick: () => void
isExpanded: boolean
expandToLastHeight: () => void
showInput: boolean
setShowInput: (show: boolean) => void
hasInputData: boolean
isPlaygroundEnabled: boolean
shouldShowTrainingButton: boolean
isTraining: boolean
handleTrainingClick: (e: React.MouseEvent) => void
showCopySuccess: boolean
handleCopy: () => void
filteredEntries: ConsoleEntry[]
handleExportConsole: (e: React.MouseEvent) => void
hasActiveFilters: boolean
handleClearConsole: (e: React.MouseEvent) => void
shouldShowCodeDisplay: boolean
outputDataStringified: string
outputData: unknown
handleClearConsoleFromMenu: () => void
filters: TerminalFilters
toggleBlock: (blockId: string) => void
toggleStatus: (status: 'error' | 'info') => void
uniqueBlocks: BlockInfo[]
}
/**
* 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,
handleOutputPanelResizeMouseDown,
handleHeaderClick,
isExpanded,
expandToLastHeight,
showInput,
setShowInput,
hasInputData,
isPlaygroundEnabled,
shouldShowTrainingButton,
isTraining,
handleTrainingClick,
showCopySuccess,
handleCopy,
filteredEntries,
handleExportConsole,
hasActiveFilters,
handleClearConsole,
shouldShowCodeDisplay,
outputDataStringified,
outputData,
handleClearConsoleFromMenu,
filters,
toggleBlock,
toggleStatus,
uniqueBlocks,
}: 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,
setSearchQuery: setOutputSearchQuery,
matchCount,
currentMatchIndex,
activateSearch: activateOutputSearch,
closeSearch: closeOutputSearch,
goToNextMatch,
goToPreviousMatch,
handleMatchCountChange,
searchInputRef: outputSearchInputRef,
} = useCodeViewerFeatures({
contentRef: outputContentRef,
externalWrapText: wrapText,
onWrapTextChange: setWrapText,
})
// Context menu state for output panel
const [hasSelection, setHasSelection] = useState(false)
const [storedSelectionText, setStoredSelectionText] = useState('')
const {
isOpen: isOutputMenuOpen,
position: outputMenuPosition,
menuRef: outputMenuRef,
handleContextMenu: handleOutputContextMenu,
closeMenu: closeOutputMenu,
} = useContextMenu()
const handleOutputPanelContextMenu = useCallback(
(e: React.MouseEvent) => {
const selection = window.getSelection()
const selectionText = selection?.toString() || ''
setStoredSelectionText(selectionText)
setHasSelection(selectionText.length > 0)
handleOutputContextMenu(e)
},
[handleOutputContextMenu]
)
const handleCopySelection = useCallback(() => {
if (storedSelectionText) {
navigator.clipboard.writeText(storedSelectionText)
}
}, [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 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
* state from changing mid-click (which would disable the copy button).
*/
useEffect(() => {
const handleSelectionChange = () => {
if (isOutputMenuOpen) return
const selection = window.getSelection()
setHasSelection(Boolean(selection && selection.toString().length > 0))
}
document.addEventListener('selectionchange', handleSelectionChange)
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
className='absolute top-0 right-0 bottom-0 flex flex-col border-[var(--border)] border-l bg-[var(--surface-1)]'
style={{ width: `${outputPanelWidth}px` }}
>
{/* Horizontal Resize Handle */}
<div
className='-ml-[4px] absolute top-0 bottom-0 left-0 z-20 w-[8px] cursor-ew-resize'
onMouseDown={handleOutputPanelResizeMouseDown}
role='separator'
aria-label='Resize output panel'
aria-orientation='vertical'
/>
{/* Header */}
<div
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--surface-1)] pr-[16px] pl-[10px]'
onClick={handleHeaderClick}
>
<div className='flex items-center'>
<Button
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={handleOutputButtonClick}
aria-label='Show output'
>
Output
</Button>
{hasInputData && (
<Button
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={handleInputButtonClick}
aria-label='Show input'
>
Input
</Button>
)}
</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}
uniqueBlocks={uniqueBlocks}
hasActiveFilters={hasActiveFilters}
/>
)}
{isOutputSearchActive ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleCloseSearchClick}
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Close search</span>
</Tooltip.Content>
</Tooltip.Root>
) : (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleSearchClick}
aria-label='Search in output'
className='!p-1.5 -m-1.5'
>
<Search className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Search</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{isPlaygroundEnabled && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Link href='/playground'>
<Button
variant='ghost'
aria-label='Component Playground'
className='!p-1.5 -m-1.5'
>
<Palette className='h-[12px] w-[12px]' />
</Button>
</Link>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Component Playground</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{shouldShowTrainingButton && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleTrainingClick}
aria-label={isTraining ? 'Stop training' : 'Train Copilot'}
className={clsx(
'!p-1.5 -m-1.5',
isTraining && 'text-orange-600 dark:text-orange-400'
)}
>
{isTraining ? (
<Pause className='h-[12px] w-[12px]' />
) : (
<Database className='h-[12px] w-[12px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{isTraining ? 'Stop Training' : 'Train Copilot'}</span>
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleCopyClick}
aria-label='Copy output'
className='!p-1.5 -m-1.5'
>
{showCopySuccess ? (
<Check className='h-[12px] w-[12px]' />
) : (
<Clipboard className='h-[12px] w-[12px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{showCopySuccess ? 'Copied' : 'Copy output'}</span>
</Tooltip.Content>
</Tooltip.Root>
{filteredEntries.length > 0 && (
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleExportConsole}
aria-label='Download console CSV'
className='!p-1.5 -m-1.5'
>
<ArrowDownToLine className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Download CSV</span>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleClearConsole}
aria-label='Clear console'
className='!p-1.5 -m-1.5'
>
<Trash2 className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys='⌘D'>Clear console</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
</>
)}
<Popover open={outputOptionsOpen} onOpenChange={setOutputOptionsOpen} size='sm'>
<PopoverTrigger asChild>
<Button
variant='ghost'
onClick={(e) => e.stopPropagation()}
aria-label='Terminal options'
className='!p-1.5 -m-1.5'
>
<MoreHorizontal className='h-3.5 w-3.5' />
</Button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={4}
collisionPadding={0}
onClick={(e) => e.stopPropagation()}
style={{ minWidth: '140px', maxWidth: '160px' }}
className='gap-[2px]'
>
<PopoverItem
active={structuredView}
showCheck={structuredView}
onClick={handleToggleStructuredView}
>
<span>Structured view</span>
</PopoverItem>
<PopoverItem active={wrapText} showCheck={wrapText} onClick={handleToggleWrapText}>
<span>Wrap text</span>
</PopoverItem>
<PopoverItem
active={openOnRun}
showCheck={openOnRun}
onClick={handleToggleOpenOnRun}
>
<span>Open on run</span>
</PopoverItem>
</PopoverContent>
</Popover>
<ToggleButton isExpanded={isExpanded} onClick={handleToggleButtonClick} />
</div>
</div>
{/* Search Overlay */}
{isOutputSearchActive && (
<div
className='absolute top-[30px] right-[8px] z-30 flex h-[34px] items-center gap-[6px] rounded-b-[4px] border border-[var(--border)] border-t-0 bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
data-toolbar-root
data-search-active='true'
>
<Input
ref={outputSearchInputRef}
type='text'
value={outputSearchQuery}
onChange={(e) => setOutputSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={clsx(
'w-[58px] font-medium text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results'}
</span>
<Button
variant='ghost'
onClick={goToPreviousMatch}
aria-label='Previous match'
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
onClick={goToNextMatch}
aria-label='Next match'
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
onClick={closeOutputSearch}
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Content */}
<div
className={clsx('flex-1 overflow-y-auto', !wrapText && 'overflow-x-auto')}
onContextMenu={handleOutputPanelContextMenu}
>
{shouldShowCodeDisplay ? (
<OutputCodeContent
code={selectedEntry.input.code}
language={(selectedEntry.input.language as 'javascript' | 'json') || 'javascript'}
wrapText={wrapText}
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/>
) : structuredView ? (
<StructuredOutput
data={outputData}
wrapText={wrapText}
isError={!showInput && Boolean(selectedEntry.error)}
isRunning={!showInput && Boolean(selectedEntry.isRunning)}
className='min-h-full'
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/>
) : (
<OutputCodeContent
code={outputDataStringified}
language='json'
wrapText={wrapText}
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/>
)}
</div>
</div>
{/* Output Panel Context Menu */}
<OutputContextMenu
isOpen={isOutputMenuOpen}
position={outputMenuPosition}
menuRef={outputMenuRef}
onClose={closeOutputMenu}
onCopySelection={handleCopySelection}
onCopyAll={handleCopy}
onSearch={activateOutputSearch}
structuredView={structuredView}
onToggleStructuredView={handleToggleStructuredView}
wrapText={wrapText}
onToggleWrap={handleToggleWrapText}
openOnRun={openOnRun}
onToggleOpenOnRun={handleToggleOpenOnRun}
onClearConsole={handleClearConsoleFromMenu}
hasSelection={hasSelection}
/>
</>
)
})

View File

@@ -0,0 +1 @@
export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display'

View File

@@ -0,0 +1,43 @@
'use client'
import { memo } from 'react'
import { Badge } from '@/components/emcn'
import { BADGE_STYLE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
/**
* Running badge component - displays a consistent "Running" indicator
*/
export const RunningBadge = memo(function RunningBadge() {
return (
<Badge variant='green' className={BADGE_STYLE}>
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 @@
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,9 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { OUTPUT_PANEL_WIDTH } from '@/stores/constants'
import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
import { useTerminalStore } from '@/stores/terminal'
const BLOCK_COLUMN_WIDTH = 240
export function useOutputPanelResize() {
const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth)
const [isResizing, setIsResizing] = useState(false)
@@ -25,7 +23,7 @@ export function useOutputPanelResize() {
const newWidth = window.innerWidth - e.clientX - panelWidth
const terminalWidth = window.innerWidth - sidebarWidth - panelWidth
const maxWidth = terminalWidth - BLOCK_COLUMN_WIDTH
const maxWidth = terminalWidth - TERMINAL_BLOCK_COLUMN_WIDTH
const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth))
setOutputPanelWidth(clampedWidth)

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.
@@ -31,7 +15,6 @@ export function useTerminalFilters() {
const [filters, setFilters] = useState<TerminalFilters>({
blockIds: new Set(),
statuses: new Set(),
runIds: new Set(),
})
const [sortConfig, setSortConfig] = useState<SortConfig>({
@@ -69,21 +52,6 @@ export function useTerminalFilters() {
})
}, [])
/**
* Toggles a run ID filter
*/
const toggleRunId = useCallback((runId: string) => {
setFilters((prev) => {
const newRunIds = new Set(prev.runIds)
if (newRunIds.has(runId)) {
newRunIds.delete(runId)
} else {
newRunIds.add(runId)
}
return { ...prev, runIds: newRunIds }
})
}, [])
/**
* Toggles sort direction between ascending and descending
*/
@@ -101,7 +69,6 @@ export function useTerminalFilters() {
setFilters({
blockIds: new Set(),
statuses: new Set(),
runIds: new Set(),
})
}, [])
@@ -109,7 +76,7 @@ export function useTerminalFilters() {
* Checks if any filters are active
*/
const hasActiveFilters = useMemo(() => {
return filters.blockIds.size > 0 || filters.statuses.size > 0 || filters.runIds.size > 0
return filters.blockIds.size > 0 || filters.statuses.size > 0
}, [filters])
/**
@@ -134,14 +101,6 @@ export function useTerminalFilters() {
if (!hasStatus) return false
}
// Run ID filter
if (
filters.runIds.size > 0 &&
(!entry.executionId || !filters.runIds.has(entry.executionId))
) {
return false
}
return true
})
}
@@ -164,7 +123,6 @@ export function useTerminalFilters() {
sortConfig,
toggleBlock,
toggleStatus,
toggleRunId,
toggleSort,
clearFilters,
hasActiveFilters,

View File

@@ -0,0 +1,64 @@
/**
* Terminal filter configuration state
*/
export interface TerminalFilters {
blockIds: Set<string>
statuses: Set<'error' | 'info'>
}
/**
* 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_STYLE = 'rounded-[4px] px-[4px] py-[0px] text-[11px]'

View File

@@ -0,0 +1,452 @@
import type React from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { getBlock } from '@/blocks'
import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
import type { ConsoleEntry } from '@/stores/terminal'
/**
* Subflow colors matching the subflow tool configs
*/
const SUBFLOW_COLORS = {
loop: '#2FB3FF',
parallel: '#FEE12B',
} 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`
}
/**
* 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
}
/**
* Terminal height configuration constants
*/
export const TERMINAL_CONFIG = {
NEAR_MIN_THRESHOLD: 40,
BLOCK_COLUMN_WIDTH_PX: TERMINAL_BLOCK_COLUMN_WIDTH,
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

@@ -1141,15 +1141,17 @@ function PreviewEditorContent({
<div className='relative flex h-full w-80 flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
{/* Header - styled like editor */}
<div className='mx-[-1px] flex flex-shrink-0 items-center gap-[8px] rounded-b-[4px] border-[var(--border)] border-x border-b bg-[var(--surface-4)] px-[12px] py-[6px]'>
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ backgroundColor: blockConfig.bgColor }}
>
<IconComponent
icon={blockConfig.icon}
className='h-[12px] w-[12px] text-[var(--white)]'
/>
</div>
{block.type !== 'note' && (
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ backgroundColor: blockConfig.bgColor }}
>
<IconComponent
icon={blockConfig.icon}
className='h-[12px] w-[12px] text-[var(--white)]'
/>
</div>
)}
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{block.name || blockConfig.name}
</span>

View File

@@ -411,8 +411,9 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
const IconComponent = blockConfig.icon
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
const isNoteBlock = type === 'note'
const shouldShowDefaultHandles = !isStarterOrTrigger
const shouldShowDefaultHandles = !isStarterOrTrigger && !isNoteBlock
const hasSubBlocks = visibleSubBlocks.length > 0
const hasContentBelowHeader =
type === 'condition'
@@ -574,8 +575,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
</>
)}
{/* Source and error handles for non-condition/router blocks */}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
{/* Source and error handles for non-condition/router/note blocks */}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && !isNoteBlock && (
<>
<Handle
type='source'

View File

@@ -406,9 +406,11 @@ export function PreviewWorkflow({
}
}
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
nodeArray.push({
id: blockId,
type: 'workflowBlock',
type: nodeType,
position: absolutePosition,
draggable: false,
zIndex: block.data?.parentId ? 10 : undefined,

File diff suppressed because it is too large Load Diff

View File

@@ -61,3 +61,6 @@ export const OUTPUT_PANEL_WIDTH = {
DEFAULT: 440,
MIN: 440,
} as const
/** Terminal block column width - minimum width for the logs column */
export const TERMINAL_BLOCK_COLUMN_WIDTH = 240 as const

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
}

View File

@@ -69,6 +69,15 @@ export const useTerminalStore = create<TerminalState>()(
setWrapText: (wrap) => {
set({ wrapText: wrap })
},
structuredView: true,
/**
* Enables or disables structured view mode in the output panel.
*
* @param structured - Whether output should be displayed as nested blocks.
*/
setStructuredView: (structured) => {
set({ structuredView: structured })
},
/**
* Indicates whether the terminal store has finished client-side hydration.
*/

View File

@@ -19,6 +19,8 @@ export interface TerminalState {
setOpenOnRun: (open: boolean) => void
wrapText: boolean
setWrapText: (wrap: boolean) => void
structuredView: boolean
setStructuredView: (structured: boolean) => void
/**
* Indicates whether the terminal is currently being resized via mouse drag.
*