mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-28 08:18:09 -05:00
Compare commits
5 Commits
fix/cmdk
...
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))
|
return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
|
||||||
}, [span, spanId, spanStartTime])
|
}, [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 isExpanded = isRootWorkflow || expandedNodes.has(spanId)
|
||||||
const isToggleable = !isRootWorkflow
|
const isToggleable = !isRootWorkflow
|
||||||
|
|
||||||
@@ -685,7 +697,7 @@ const TraceSpanNode = memo(function TraceSpanNode({
|
|||||||
{/* Nested Children */}
|
{/* Nested Children */}
|
||||||
{hasChildren && (
|
{hasChildren && (
|
||||||
<div className='flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[10px]'>
|
<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]'>
|
<div key={child.id || `${spanId}-child-${index}`} className='pl-[6px]'>
|
||||||
<TraceSpanNode
|
<TraceSpanNode
|
||||||
span={child}
|
span={child}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ChevronDown, RepeatIcon, SplitIcon } from 'lucide-react'
|
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { ChevronDown } from '@/components/emcn'
|
||||||
import {
|
import {
|
||||||
FieldItem,
|
FieldItem,
|
||||||
type SchemaField,
|
type SchemaField,
|
||||||
@@ -115,9 +116,8 @@ function ConnectionItem({
|
|||||||
{hasFields && (
|
{hasFields && (
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
|
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
|
||||||
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]',
|
!isExpanded && '-rotate-90'
|
||||||
isExpanded && 'rotate-180'
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { FilterPopover, type FilterPopoverProps } from './filter-popover'
|
||||||
export { OutputContextMenu } from './output-context-menu'
|
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'
|
'use client'
|
||||||
|
|
||||||
import type { RefObject } from 'react'
|
import { memo, type RefObject } from 'react'
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverAnchor,
|
PopoverAnchor,
|
||||||
@@ -8,20 +8,13 @@ import {
|
|||||||
PopoverDivider,
|
PopoverDivider,
|
||||||
PopoverItem,
|
PopoverItem,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
|
import type {
|
||||||
|
ContextMenuPosition,
|
||||||
|
TerminalFilters,
|
||||||
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
|
||||||
import type { ConsoleEntry } from '@/stores/terminal'
|
import type { ConsoleEntry } from '@/stores/terminal'
|
||||||
|
|
||||||
interface ContextMenuPosition {
|
export interface LogRowContextMenuProps {
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminalFilters {
|
|
||||||
blockIds: Set<string>
|
|
||||||
statuses: Set<'error' | 'info'>
|
|
||||||
runIds: Set<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LogRowContextMenuProps {
|
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
position: ContextMenuPosition
|
position: ContextMenuPosition
|
||||||
menuRef: RefObject<HTMLDivElement | null>
|
menuRef: RefObject<HTMLDivElement | null>
|
||||||
@@ -30,19 +23,16 @@ interface LogRowContextMenuProps {
|
|||||||
filters: TerminalFilters
|
filters: TerminalFilters
|
||||||
onFilterByBlock: (blockId: string) => void
|
onFilterByBlock: (blockId: string) => void
|
||||||
onFilterByStatus: (status: 'error' | 'info') => void
|
onFilterByStatus: (status: 'error' | 'info') => void
|
||||||
onFilterByRunId: (runId: string) => void
|
|
||||||
onCopyRunId: (runId: string) => void
|
onCopyRunId: (runId: string) => void
|
||||||
onClearFilters: () => void
|
|
||||||
onClearConsole: () => void
|
onClearConsole: () => void
|
||||||
onFixInCopilot: (entry: ConsoleEntry) => void
|
onFixInCopilot: (entry: ConsoleEntry) => void
|
||||||
hasActiveFilters: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context menu for terminal log rows (left side).
|
* Context menu for terminal log rows (left side).
|
||||||
* Displays filtering options based on the selected row's properties.
|
* Displays filtering options based on the selected row's properties.
|
||||||
*/
|
*/
|
||||||
export function LogRowContextMenu({
|
export const LogRowContextMenu = memo(function LogRowContextMenu({
|
||||||
isOpen,
|
isOpen,
|
||||||
position,
|
position,
|
||||||
menuRef,
|
menuRef,
|
||||||
@@ -51,19 +41,15 @@ export function LogRowContextMenu({
|
|||||||
filters,
|
filters,
|
||||||
onFilterByBlock,
|
onFilterByBlock,
|
||||||
onFilterByStatus,
|
onFilterByStatus,
|
||||||
onFilterByRunId,
|
|
||||||
onCopyRunId,
|
onCopyRunId,
|
||||||
onClearFilters,
|
|
||||||
onClearConsole,
|
onClearConsole,
|
||||||
onFixInCopilot,
|
onFixInCopilot,
|
||||||
hasActiveFilters,
|
|
||||||
}: LogRowContextMenuProps) {
|
}: LogRowContextMenuProps) {
|
||||||
const hasRunId = entry?.executionId != null
|
const hasRunId = entry?.executionId != null
|
||||||
|
|
||||||
const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false
|
const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false
|
||||||
const entryStatus = entry?.success ? 'info' : 'error'
|
const entryStatus = entry?.success ? 'info' : 'error'
|
||||||
const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false
|
const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false
|
||||||
const isRunIdFiltered = entry?.executionId ? filters.runIds.has(entry.executionId) : false
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -134,34 +120,11 @@ export function LogRowContextMenu({
|
|||||||
>
|
>
|
||||||
Filter by Status
|
Filter by Status
|
||||||
</PopoverItem>
|
</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 */}
|
{/* Destructive action */}
|
||||||
{(entry || hasActiveFilters) && <PopoverDivider />}
|
{entry && <PopoverDivider />}
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClearConsole()
|
onClearConsole()
|
||||||
@@ -173,4 +136,4 @@ export function LogRowContextMenu({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { RefObject } from 'react'
|
import { memo, type RefObject } from 'react'
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverAnchor,
|
PopoverAnchor,
|
||||||
@@ -8,13 +8,9 @@ import {
|
|||||||
PopoverDivider,
|
PopoverDivider,
|
||||||
PopoverItem,
|
PopoverItem,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
|
import type { ContextMenuPosition } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
|
||||||
|
|
||||||
interface ContextMenuPosition {
|
export interface OutputContextMenuProps {
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OutputContextMenuProps {
|
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
position: ContextMenuPosition
|
position: ContextMenuPosition
|
||||||
menuRef: RefObject<HTMLDivElement | null>
|
menuRef: RefObject<HTMLDivElement | null>
|
||||||
@@ -22,6 +18,8 @@ interface OutputContextMenuProps {
|
|||||||
onCopySelection: () => void
|
onCopySelection: () => void
|
||||||
onCopyAll: () => void
|
onCopyAll: () => void
|
||||||
onSearch: () => void
|
onSearch: () => void
|
||||||
|
structuredView: boolean
|
||||||
|
onToggleStructuredView: () => void
|
||||||
wrapText: boolean
|
wrapText: boolean
|
||||||
onToggleWrap: () => void
|
onToggleWrap: () => void
|
||||||
openOnRun: boolean
|
openOnRun: boolean
|
||||||
@@ -34,7 +32,7 @@ interface OutputContextMenuProps {
|
|||||||
* Context menu for terminal output panel (right side).
|
* Context menu for terminal output panel (right side).
|
||||||
* Displays copy, search, and display options for the code viewer.
|
* Displays copy, search, and display options for the code viewer.
|
||||||
*/
|
*/
|
||||||
export function OutputContextMenu({
|
export const OutputContextMenu = memo(function OutputContextMenu({
|
||||||
isOpen,
|
isOpen,
|
||||||
position,
|
position,
|
||||||
menuRef,
|
menuRef,
|
||||||
@@ -42,6 +40,8 @@ export function OutputContextMenu({
|
|||||||
onCopySelection,
|
onCopySelection,
|
||||||
onCopyAll,
|
onCopyAll,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
structuredView,
|
||||||
|
onToggleStructuredView,
|
||||||
wrapText,
|
wrapText,
|
||||||
onToggleWrap,
|
onToggleWrap,
|
||||||
openOnRun,
|
openOnRun,
|
||||||
@@ -96,6 +96,9 @@ export function OutputContextMenu({
|
|||||||
|
|
||||||
{/* Display settings - toggles don't close menu */}
|
{/* Display settings - toggles don't close menu */}
|
||||||
<PopoverDivider />
|
<PopoverDivider />
|
||||||
|
<PopoverItem showCheck={structuredView} onClick={onToggleStructuredView}>
|
||||||
|
Structured View
|
||||||
|
</PopoverItem>
|
||||||
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
|
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
|
||||||
Wrap Text
|
Wrap Text
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
@@ -116,4 +119,4 @@ export function OutputContextMenu({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</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 { useOutputPanelResize } from './use-output-panel-resize'
|
||||||
export { useTerminalFilters } from './use-terminal-filters'
|
export { useTerminalFilters } from './use-terminal-filters'
|
||||||
export { useTerminalResize } from './use-terminal-resize'
|
export { useTerminalResize } from './use-terminal-resize'
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
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'
|
import { useTerminalStore } from '@/stores/terminal'
|
||||||
|
|
||||||
const BLOCK_COLUMN_WIDTH = 240
|
|
||||||
|
|
||||||
export function useOutputPanelResize() {
|
export function useOutputPanelResize() {
|
||||||
const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth)
|
const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth)
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
@@ -25,7 +23,7 @@ export function useOutputPanelResize() {
|
|||||||
|
|
||||||
const newWidth = window.innerWidth - e.clientX - panelWidth
|
const newWidth = window.innerWidth - e.clientX - panelWidth
|
||||||
const terminalWidth = window.innerWidth - sidebarWidth - 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))
|
const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth))
|
||||||
|
|
||||||
setOutputPanelWidth(clampedWidth)
|
setOutputPanelWidth(clampedWidth)
|
||||||
|
|||||||
@@ -1,26 +1,10 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react'
|
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'
|
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.
|
* Custom hook to manage terminal filters and sorting.
|
||||||
* Provides filter state, sort state, and filtering/sorting logic for console entries.
|
* Provides filter state, sort state, and filtering/sorting logic for console entries.
|
||||||
@@ -31,7 +15,6 @@ export function useTerminalFilters() {
|
|||||||
const [filters, setFilters] = useState<TerminalFilters>({
|
const [filters, setFilters] = useState<TerminalFilters>({
|
||||||
blockIds: new Set(),
|
blockIds: new Set(),
|
||||||
statuses: new Set(),
|
statuses: new Set(),
|
||||||
runIds: new Set(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
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
|
* Toggles sort direction between ascending and descending
|
||||||
*/
|
*/
|
||||||
@@ -101,7 +69,6 @@ export function useTerminalFilters() {
|
|||||||
setFilters({
|
setFilters({
|
||||||
blockIds: new Set(),
|
blockIds: new Set(),
|
||||||
statuses: new Set(),
|
statuses: new Set(),
|
||||||
runIds: new Set(),
|
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -109,7 +76,7 @@ export function useTerminalFilters() {
|
|||||||
* Checks if any filters are active
|
* Checks if any filters are active
|
||||||
*/
|
*/
|
||||||
const hasActiveFilters = useMemo(() => {
|
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])
|
}, [filters])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,14 +101,6 @@ export function useTerminalFilters() {
|
|||||||
if (!hasStatus) return false
|
if (!hasStatus) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run ID filter
|
|
||||||
if (
|
|
||||||
filters.runIds.size > 0 &&
|
|
||||||
(!entry.executionId || !filters.runIds.has(entry.executionId))
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -164,7 +123,6 @@ export function useTerminalFilters() {
|
|||||||
sortConfig,
|
sortConfig,
|
||||||
toggleBlock,
|
toggleBlock,
|
||||||
toggleStatus,
|
toggleStatus,
|
||||||
toggleRunId,
|
|
||||||
toggleSort,
|
toggleSort,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
hasActiveFilters,
|
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 queryClient = useQueryClient()
|
||||||
const currentWorkflow = useCurrentWorkflow()
|
const currentWorkflow = useCurrentWorkflow()
|
||||||
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
||||||
const { toggleConsole, addConsole } = useTerminalConsoleStore()
|
const { toggleConsole, addConsole, updateConsole, cancelRunningEntries } =
|
||||||
|
useTerminalConsoleStore()
|
||||||
const { getAllVariables } = useEnvironmentStore()
|
const { getAllVariables } = useEnvironmentStore()
|
||||||
const { getVariablesByWorkflowId, variables } = useVariablesStore()
|
const { getVariablesByWorkflowId, variables } = useVariablesStore()
|
||||||
const {
|
const {
|
||||||
@@ -867,6 +868,8 @@ export function useWorkflowExecution() {
|
|||||||
if (activeWorkflowId) {
|
if (activeWorkflowId) {
|
||||||
logger.info('Using server-side executor')
|
logger.info('Using server-side executor')
|
||||||
|
|
||||||
|
const executionId = uuidv4()
|
||||||
|
|
||||||
let executionResult: ExecutionResult = {
|
let executionResult: ExecutionResult = {
|
||||||
success: false,
|
success: false,
|
||||||
output: {},
|
output: {},
|
||||||
@@ -910,6 +913,27 @@ export function useWorkflowExecution() {
|
|||||||
incomingEdges.forEach((edge) => {
|
incomingEdges.forEach((edge) => {
|
||||||
setEdgeRunStatus(edge.id, 'success')
|
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) => {
|
onBlockCompleted: (data) => {
|
||||||
@@ -940,24 +964,23 @@ export function useWorkflowExecution() {
|
|||||||
endedAt,
|
endedAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add to console
|
// Update existing console entry (created in onBlockStarted) with completion data
|
||||||
addConsole({
|
updateConsole(
|
||||||
input: data.input || {},
|
data.blockId,
|
||||||
output: data.output,
|
{
|
||||||
success: true,
|
input: data.input || {},
|
||||||
durationMs: data.durationMs,
|
replaceOutput: data.output,
|
||||||
startedAt,
|
success: true,
|
||||||
endedAt,
|
durationMs: data.durationMs,
|
||||||
workflowId: activeWorkflowId,
|
endedAt,
|
||||||
blockId: data.blockId,
|
isRunning: false,
|
||||||
executionId: executionId || uuidv4(),
|
// Pass through iteration context for subflow grouping
|
||||||
blockName: data.blockName || 'Unknown Block',
|
iterationCurrent: data.iterationCurrent,
|
||||||
blockType: data.blockType || 'unknown',
|
iterationTotal: data.iterationTotal,
|
||||||
// Pass through iteration context for console pills
|
iterationType: data.iterationType,
|
||||||
iterationCurrent: data.iterationCurrent,
|
},
|
||||||
iterationTotal: data.iterationTotal,
|
executionId
|
||||||
iterationType: data.iterationType,
|
)
|
||||||
})
|
|
||||||
|
|
||||||
// Call onBlockComplete callback if provided
|
// Call onBlockComplete callback if provided
|
||||||
if (onBlockComplete) {
|
if (onBlockComplete) {
|
||||||
@@ -992,25 +1015,24 @@ export function useWorkflowExecution() {
|
|||||||
endedAt,
|
endedAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add error to console
|
// Update existing console entry (created in onBlockStarted) with error data
|
||||||
addConsole({
|
updateConsole(
|
||||||
input: data.input || {},
|
data.blockId,
|
||||||
output: {},
|
{
|
||||||
success: false,
|
input: data.input || {},
|
||||||
error: data.error,
|
replaceOutput: {},
|
||||||
durationMs: data.durationMs,
|
success: false,
|
||||||
startedAt,
|
error: data.error,
|
||||||
endedAt,
|
durationMs: data.durationMs,
|
||||||
workflowId: activeWorkflowId,
|
endedAt,
|
||||||
blockId: data.blockId,
|
isRunning: false,
|
||||||
executionId: executionId || uuidv4(),
|
// Pass through iteration context for subflow grouping
|
||||||
blockName: data.blockName,
|
iterationCurrent: data.iterationCurrent,
|
||||||
blockType: data.blockType,
|
iterationTotal: data.iterationTotal,
|
||||||
// Pass through iteration context for console pills
|
iterationType: data.iterationType,
|
||||||
iterationCurrent: data.iterationCurrent,
|
},
|
||||||
iterationTotal: data.iterationTotal,
|
executionId
|
||||||
iterationType: data.iterationType,
|
)
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onStreamChunk: (data) => {
|
onStreamChunk: (data) => {
|
||||||
@@ -1089,7 +1111,7 @@ export function useWorkflowExecution() {
|
|||||||
endedAt: new Date().toISOString(),
|
endedAt: new Date().toISOString(),
|
||||||
workflowId: activeWorkflowId,
|
workflowId: activeWorkflowId,
|
||||||
blockId: 'validation',
|
blockId: 'validation',
|
||||||
executionId: executionId || uuidv4(),
|
executionId,
|
||||||
blockName: 'Workflow Validation',
|
blockName: 'Workflow Validation',
|
||||||
blockType: 'validation',
|
blockType: 'validation',
|
||||||
})
|
})
|
||||||
@@ -1358,6 +1380,11 @@ export function useWorkflowExecution() {
|
|||||||
// Mark current chat execution as superseded so its cleanup won't affect new executions
|
// Mark current chat execution as superseded so its cleanup won't affect new executions
|
||||||
currentChatExecutionIdRef.current = null
|
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
|
// Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx
|
||||||
setIsExecuting(false)
|
setIsExecuting(false)
|
||||||
setIsDebugging(false)
|
setIsDebugging(false)
|
||||||
@@ -1374,6 +1401,8 @@ export function useWorkflowExecution() {
|
|||||||
setIsExecuting,
|
setIsExecuting,
|
||||||
setIsDebugging,
|
setIsDebugging,
|
||||||
setActiveBlocks,
|
setActiveBlocks,
|
||||||
|
activeWorkflowId,
|
||||||
|
cancelRunningEntries,
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
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)]'>
|
<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 */}
|
{/* 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='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
|
{block.type !== 'note' && (
|
||||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
<div
|
||||||
style={{ backgroundColor: blockConfig.bgColor }}
|
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||||
>
|
style={{ backgroundColor: blockConfig.bgColor }}
|
||||||
<IconComponent
|
>
|
||||||
icon={blockConfig.icon}
|
<IconComponent
|
||||||
className='h-[12px] w-[12px] text-[var(--white)]'
|
icon={blockConfig.icon}
|
||||||
/>
|
className='h-[12px] w-[12px] text-[var(--white)]'
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||||
{block.name || blockConfig.name}
|
{block.name || blockConfig.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -411,8 +411,9 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
|||||||
|
|
||||||
const IconComponent = blockConfig.icon
|
const IconComponent = blockConfig.icon
|
||||||
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
|
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 hasSubBlocks = visibleSubBlocks.length > 0
|
||||||
const hasContentBelowHeader =
|
const hasContentBelowHeader =
|
||||||
type === 'condition'
|
type === 'condition'
|
||||||
@@ -574,8 +575,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Source and error handles for non-condition/router blocks */}
|
{/* Source and error handles for non-condition/router/note blocks */}
|
||||||
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
|
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && !isNoteBlock && (
|
||||||
<>
|
<>
|
||||||
<Handle
|
<Handle
|
||||||
type='source'
|
type='source'
|
||||||
|
|||||||
@@ -406,9 +406,11 @@ export function PreviewWorkflow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
||||||
|
|
||||||
nodeArray.push({
|
nodeArray.push({
|
||||||
id: blockId,
|
id: blockId,
|
||||||
type: 'workflowBlock',
|
type: nodeType,
|
||||||
position: absolutePosition,
|
position: absolutePosition,
|
||||||
draggable: false,
|
draggable: false,
|
||||||
zIndex: block.data?.parentId ? 10 : undefined,
|
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,
|
DEFAULT: 440,
|
||||||
MIN: 440,
|
MIN: 440,
|
||||||
} as const
|
} 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
|
: 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 updatedEntry
|
||||||
})
|
})
|
||||||
|
|
||||||
return { entries: updatedEntries }
|
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',
|
name: 'terminal-console-store',
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface ConsoleEntry {
|
|||||||
iterationCurrent?: number
|
iterationCurrent?: number
|
||||||
iterationTotal?: number
|
iterationTotal?: number
|
||||||
iterationType?: SubflowType
|
iterationType?: SubflowType
|
||||||
|
/** Whether this block is currently running */
|
||||||
|
isRunning?: boolean
|
||||||
|
/** Whether this block execution was canceled */
|
||||||
|
isCanceled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsoleUpdate {
|
export interface ConsoleUpdate {
|
||||||
@@ -32,6 +36,14 @@ export interface ConsoleUpdate {
|
|||||||
endedAt?: string
|
endedAt?: string
|
||||||
durationMs?: number
|
durationMs?: number
|
||||||
input?: any
|
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 {
|
export interface ConsoleStore {
|
||||||
@@ -43,6 +55,7 @@ export interface ConsoleStore {
|
|||||||
getWorkflowEntries: (workflowId: string) => ConsoleEntry[]
|
getWorkflowEntries: (workflowId: string) => ConsoleEntry[]
|
||||||
toggleConsole: () => void
|
toggleConsole: () => void
|
||||||
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
|
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
|
||||||
|
cancelRunningEntries: (workflowId: string) => void
|
||||||
_hasHydrated: boolean
|
_hasHydrated: boolean
|
||||||
setHasHydrated: (hasHydrated: boolean) => void
|
setHasHydrated: (hasHydrated: boolean) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ export const useTerminalStore = create<TerminalState>()(
|
|||||||
setWrapText: (wrap) => {
|
setWrapText: (wrap) => {
|
||||||
set({ wrapText: 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.
|
* Indicates whether the terminal store has finished client-side hydration.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface TerminalState {
|
|||||||
setOpenOnRun: (open: boolean) => void
|
setOpenOnRun: (open: boolean) => void
|
||||||
wrapText: boolean
|
wrapText: boolean
|
||||||
setWrapText: (wrap: boolean) => void
|
setWrapText: (wrap: boolean) => void
|
||||||
|
structuredView: boolean
|
||||||
|
setStructuredView: (structured: boolean) => void
|
||||||
/**
|
/**
|
||||||
* Indicates whether the terminal is currently being resized via mouse drag.
|
* Indicates whether the terminal is currently being resized via mouse drag.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user