mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-28 08:18:09 -05:00
Compare commits
5 Commits
fix/keyboa
...
feat/termi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e29dbb318 | ||
|
|
07be51e7a1 | ||
|
|
eaa658b16e | ||
|
|
d8d4a6168c | ||
|
|
efefc16199 |
@@ -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,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>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { FilterPopover, type FilterPopoverProps } from './filter-popover'
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display'
|
||||
@@ -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}</>
|
||||
})
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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]'
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user