improvement(terminal): ui/ux

This commit is contained in:
Emir Karabeg
2026-01-28 02:01:46 -08:00
parent 07be51e7a1
commit 2e29dbb318
12 changed files with 160 additions and 586 deletions

View File

@@ -17,11 +17,7 @@ import type {
BlockInfo,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import {
formatRunId,
getBlockIcon,
getRunIdColor,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils'
import { getBlockIcon } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils'
/**
* Props for the FilterPopover component
@@ -32,10 +28,7 @@ export interface FilterPopoverProps {
filters: TerminalFilters
toggleStatus: (status: 'error' | 'info') => void
toggleBlock: (blockId: string) => void
toggleRunId: (runId: string) => void
uniqueBlocks: BlockInfo[]
uniqueRunIds: string[]
executionColorMap: Map<string, string>
hasActiveFilters: boolean
}
@@ -48,10 +41,7 @@ export const FilterPopover = memo(function FilterPopover({
filters,
toggleStatus,
toggleBlock,
toggleRunId,
uniqueBlocks,
uniqueRunIds,
executionColorMap,
hasActiveFilters,
}: FilterPopoverProps) {
return (
@@ -69,7 +59,7 @@ export const FilterPopover = memo(function FilterPopover({
</Button>
</PopoverTrigger>
<PopoverContent
side='bottom'
side='top'
align='end'
sideOffset={4}
onClick={(e) => e.stopPropagation()}
@@ -103,7 +93,7 @@ export const FilterPopover = memo(function FilterPopover({
{uniqueBlocks.length > 0 && (
<>
<PopoverDivider />
<PopoverDivider className='my-[4px]' />
<PopoverSection className='!mt-0'>Blocks</PopoverSection>
<PopoverScrollArea className='max-h-[100px]'>
{uniqueBlocks.map((block) => {
@@ -125,35 +115,6 @@ export const FilterPopover = memo(function FilterPopover({
</PopoverScrollArea>
</>
)}
{uniqueRunIds.length > 0 && (
<>
<PopoverDivider />
<PopoverSection className='!mt-0'>Run ID</PopoverSection>
<PopoverScrollArea className='max-h-[100px]'>
{uniqueRunIds.map((runId) => {
const isSelected = filters.runIds.has(runId)
const runIdColor = getRunIdColor(runId, executionColorMap)
return (
<PopoverItem
key={runId}
active={isSelected}
showCheck={isSelected}
onClick={() => toggleRunId(runId)}
>
<span
className='flex-1 font-mono text-[11px]'
style={{ color: runIdColor || '#D2D2D2' }}
>
{formatRunId(runId)}
</span>
</PopoverItem>
)
})}
</PopoverScrollArea>
</>
)}
</PopoverContent>
</Popover>
)

View File

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

View File

@@ -23,12 +23,9 @@ export interface LogRowContextMenuProps {
filters: TerminalFilters
onFilterByBlock: (blockId: string) => void
onFilterByStatus: (status: 'error' | 'info') => void
onFilterByRunId: (runId: string) => void
onCopyRunId: (runId: string) => void
onClearFilters: () => void
onClearConsole: () => void
onFixInCopilot: (entry: ConsoleEntry) => void
hasActiveFilters: boolean
}
/**
@@ -44,19 +41,15 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
filters,
onFilterByBlock,
onFilterByStatus,
onFilterByRunId,
onCopyRunId,
onClearFilters,
onClearConsole,
onFixInCopilot,
hasActiveFilters,
}: LogRowContextMenuProps) {
const hasRunId = entry?.executionId != null
const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false
const entryStatus = entry?.success ? 'info' : 'error'
const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false
const isRunIdFiltered = entry?.executionId ? filters.runIds.has(entry.executionId) : false
return (
<Popover
@@ -127,34 +120,11 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
>
Filter by Status
</PopoverItem>
{hasRunId && (
<PopoverItem
showCheck={isRunIdFiltered}
onClick={() => {
onFilterByRunId(entry.executionId!)
onClose()
}}
>
Filter by Run ID
</PopoverItem>
)}
</>
)}
{/* Clear filters */}
{hasActiveFilters && (
<PopoverItem
onClick={() => {
onClearFilters()
onClose()
}}
>
Clear All Filters
</PopoverItem>
)}
{/* Destructive action */}
{(entry || hasActiveFilters) && <PopoverDivider />}
{entry && <PopoverDivider />}
<PopoverItem
onClick={() => {
onClearConsole()

View File

@@ -9,7 +9,6 @@ import {
Check,
Clipboard,
Database,
FilterX,
MoreHorizontal,
Palette,
Pause,
@@ -102,7 +101,6 @@ export interface OutputPanelProps {
filteredEntries: ConsoleEntry[]
handleExportConsole: (e: React.MouseEvent) => void
hasActiveFilters: boolean
clearFilters: () => void
handleClearConsole: (e: React.MouseEvent) => void
shouldShowCodeDisplay: boolean
outputDataStringified: string
@@ -111,10 +109,7 @@ export interface OutputPanelProps {
filters: TerminalFilters
toggleBlock: (blockId: string) => void
toggleStatus: (status: 'error' | 'info') => void
toggleRunId: (runId: string) => void
uniqueBlocks: BlockInfo[]
uniqueRunIds: string[]
executionColorMap: Map<string, string>
}
/**
@@ -139,7 +134,6 @@ export const OutputPanel = React.memo(function OutputPanel({
filteredEntries,
handleExportConsole,
hasActiveFilters,
clearFilters,
handleClearConsole,
shouldShowCodeDisplay,
outputDataStringified,
@@ -148,10 +142,7 @@ export const OutputPanel = React.memo(function OutputPanel({
filters,
toggleBlock,
toggleStatus,
toggleRunId,
uniqueBlocks,
uniqueRunIds,
executionColorMap,
}: OutputPanelProps) {
// Access store-backed settings directly to reduce prop drilling
const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth)
@@ -224,14 +215,6 @@ export const OutputPanel = React.memo(function OutputPanel({
setOpenOnRun(!openOnRun)
}, [openOnRun, setOpenOnRun])
const handleClearFiltersClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
clearFilters()
},
[clearFilters]
)
const handleCopyClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -364,10 +347,7 @@ export const OutputPanel = React.memo(function OutputPanel({
filters={filters}
toggleStatus={toggleStatus}
toggleBlock={toggleBlock}
toggleRunId={toggleRunId}
uniqueBlocks={uniqueBlocks}
uniqueRunIds={uniqueRunIds}
executionColorMap={executionColorMap}
hasActiveFilters={hasActiveFilters}
/>
)}
@@ -470,55 +450,38 @@ export const OutputPanel = React.memo(function OutputPanel({
</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>
)}
{hasActiveFilters && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleClearFiltersClick}
aria-label='Clear filters'
className='!p-1.5 -m-1.5'
>
<FilterX className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Clear filters</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{filteredEntries.length > 0 && (
<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>
<>
<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>

View File

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

View File

@@ -0,0 +1,43 @@
'use client'
import { memo } from 'react'
import { Badge } from '@/components/emcn'
import { BADGE_STYLE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
/**
* Running badge component - displays a consistent "Running" indicator
*/
export const RunningBadge = memo(function RunningBadge() {
return (
<Badge variant='green' className={BADGE_STYLE}>
Running
</Badge>
)
})
/**
* Props for StatusDisplay component
*/
export interface StatusDisplayProps {
isRunning: boolean
isCanceled: boolean
formattedDuration: string
}
/**
* Reusable status display for terminal rows.
* Shows Running badge, 'canceled' text, or formatted duration.
*/
export const StatusDisplay = memo(function StatusDisplay({
isRunning,
isCanceled,
formattedDuration,
}: StatusDisplayProps) {
if (isRunning) {
return <RunningBadge />
}
if (isCanceled) {
return <>canceled</>
}
return <>{formattedDuration}</>
})

View File

@@ -1,9 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { OUTPUT_PANEL_WIDTH } from '@/stores/constants'
import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
import { useTerminalStore } from '@/stores/terminal'
const BLOCK_COLUMN_WIDTH = 240
export function useOutputPanelResize() {
const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth)
const [isResizing, setIsResizing] = useState(false)
@@ -25,7 +23,7 @@ export function useOutputPanelResize() {
const newWidth = window.innerWidth - e.clientX - panelWidth
const terminalWidth = window.innerWidth - sidebarWidth - panelWidth
const maxWidth = terminalWidth - BLOCK_COLUMN_WIDTH
const maxWidth = terminalWidth - TERMINAL_BLOCK_COLUMN_WIDTH
const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth))
setOutputPanelWidth(clampedWidth)

View File

@@ -15,7 +15,6 @@ export function useTerminalFilters() {
const [filters, setFilters] = useState<TerminalFilters>({
blockIds: new Set(),
statuses: new Set(),
runIds: new Set(),
})
const [sortConfig, setSortConfig] = useState<SortConfig>({
@@ -53,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
*/
@@ -85,7 +69,6 @@ export function useTerminalFilters() {
setFilters({
blockIds: new Set(),
statuses: new Set(),
runIds: new Set(),
})
}, [])
@@ -93,7 +76,7 @@ export function useTerminalFilters() {
* Checks if any filters are active
*/
const hasActiveFilters = useMemo(() => {
return filters.blockIds.size > 0 || filters.statuses.size > 0 || filters.runIds.size > 0
return filters.blockIds.size > 0 || filters.statuses.size > 0
}, [filters])
/**
@@ -118,14 +101,6 @@ export function useTerminalFilters() {
if (!hasStatus) return false
}
// Run ID filter
if (
filters.runIds.size > 0 &&
(!entry.executionId || !filters.runIds.has(entry.executionId))
) {
return false
}
return true
})
}
@@ -148,7 +123,6 @@ export function useTerminalFilters() {
sortConfig,
toggleBlock,
toggleStatus,
toggleRunId,
toggleSort,
clearFilters,
hasActiveFilters,

View File

@@ -8,7 +8,6 @@ import {
ArrowDownToLine,
ArrowUp,
Database,
FilterX,
MoreHorizontal,
Palette,
Pause,
@@ -16,7 +15,6 @@ import {
} from 'lucide-react'
import Link from 'next/link'
import {
Badge,
Button,
ChevronDown,
Popover,
@@ -32,6 +30,7 @@ import {
FilterPopover,
LogRowContextMenu,
OutputPanel,
StatusDisplay,
ToggleButton,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components'
import {
@@ -39,23 +38,17 @@ import {
useTerminalFilters,
useTerminalResize,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks'
import {
BADGE_STYLES,
ROW_STYLES,
StatusDisplay,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import { ROW_STYLES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import {
type EntryNode,
type ExecutionGroup,
flattenBlockEntriesOnly,
formatDuration,
formatRunId,
getBlockColor,
getBlockIcon,
groupEntriesByExecution,
isEventFromEditableElement,
type NavigableBlockEntry,
RUN_ID_COLORS,
TERMINAL_CONFIG,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -165,7 +158,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
const hasCanceledChild = children.some((c) => c.entry.isCanceled) && !hasRunningChild
const iterationLabel = iterationInfo
? `Iteration ${iterationInfo.current}${iterationInfo.total !== undefined ? ` / ${iterationInfo.total}` : ''}`
? `Iteration ${iterationInfo.current + 1}${iterationInfo.total !== undefined ? ` / ${iterationInfo.total}` : ''}`
: entry.blockName
return (
@@ -398,124 +391,43 @@ const EntryNodeRow = memo(function EntryNodeRow({
})
/**
* Status badge component for execution rows
* Execution group row component with dashed separator
*/
const StatusBadge = memo(function StatusBadge({
hasError,
isRunning,
isCanceled,
}: {
hasError: boolean
isRunning: boolean
isCanceled: boolean
}) {
if (isRunning) {
return (
<Badge variant='green' className={BADGE_STYLES.base}>
Running
</Badge>
)
}
if (isCanceled) {
return (
<Badge variant='gray' className={BADGE_STYLES.mono}>
canceled
</Badge>
)
}
return (
<Badge variant={hasError ? 'red' : 'gray'} className={BADGE_STYLES.mono}>
{hasError ? 'error' : 'info'}
</Badge>
)
})
/**
* Execution row component with expand/collapse
*/
const ExecutionRow = memo(function ExecutionRow({
const ExecutionGroupRow = memo(function ExecutionGroupRow({
group,
isExpanded,
onToggle,
showSeparator,
selectedEntryId,
onSelectEntry,
expandedNodes,
onToggleNode,
}: {
group: ExecutionGroup
isExpanded: boolean
onToggle: () => void
showSeparator: boolean
selectedEntryId: string | null
onSelectEntry: (entry: ConsoleEntry) => void
expandedNodes: Set<string>
onToggleNode: (nodeId: string) => void
}) {
const hasError = group.status === 'error'
const hasRunningEntry = group.entries.some((entry) => entry.isRunning)
const hasCanceledEntry = group.entries.some((entry) => entry.isCanceled) && !hasRunningEntry
return (
<div className='flex flex-col px-[6px]'>
{/* Execution header */}
<div
className={clsx(ROW_STYLES.base, 'ml-[4px] h-[24px]', ROW_STYLES.hover)}
onClick={onToggle}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span
className={clsx(
'font-medium text-[13px]',
isExpanded
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
>
Run #{formatRunId(group.executionId)}
</span>
<StatusBadge
hasError={hasError}
isRunning={hasRunningEntry}
isCanceled={hasCanceledEntry}
/>
<ChevronDown
className={clsx(
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
!isExpanded && '-rotate-90'
)}
/>
</div>
<span
className={clsx(
'flex-shrink-0 font-medium text-[13px]',
!hasRunningEntry &&
(hasCanceledEntry ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]')
)}
>
<StatusDisplay
isRunning={hasRunningEntry}
isCanceled={hasCanceledEntry}
formattedDuration={formatDuration(group.duration)}
/>
</span>
</div>
{/* Expanded content - Tree structure */}
{isExpanded && (
<div className='flex flex-col pb-[4px] pl-[10px]'>
<div className={ROW_STYLES.nested}>
{group.entryTree.map((node) => (
<EntryNodeRow
key={node.entry.id}
node={node}
selectedEntryId={selectedEntryId}
onSelectEntry={onSelectEntry}
expandedNodes={expandedNodes}
onToggleNode={onToggleNode}
/>
))}
</div>
</div>
{/* Dashed separator between executions */}
{showSeparator && (
<div className='mx-[4px] my-[4px] border-[var(--border)] border-t border-dashed' />
)}
{/* Entry tree */}
<div className='ml-[4px] flex flex-col gap-[2px] pb-[4px]'>
{group.entryTree.map((node) => (
<EntryNodeRow
key={node.entry.id}
node={node}
selectedEntryId={selectedEntryId}
onSelectEntry={onSelectEntry}
expandedNodes={expandedNodes}
onToggleNode={onToggleNode}
/>
))}
</div>
</div>
)
})
@@ -526,7 +438,6 @@ const ExecutionRow = memo(function ExecutionRow({
export const Terminal = memo(function Terminal() {
const terminalRef = useRef<HTMLElement>(null)
const logsContainerRef = useRef<HTMLDivElement>(null)
const prevEntriesLengthRef = useRef(0)
const prevWorkflowEntriesLengthRef = useRef(0)
const isTerminalFocusedRef = useRef(false)
const lastExpandedHeightRef = useRef<number>(DEFAULT_EXPANDED_HEIGHT)
@@ -543,10 +454,6 @@ export const Terminal = memo(function Terminal() {
const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth)
const openOnRun = useTerminalStore((state) => state.openOnRun)
const setOpenOnRun = useTerminalStore((state) => state.setOpenOnRun)
const wrapText = useTerminalStore((state) => state.wrapText)
const setWrapText = useTerminalStore((state) => state.setWrapText)
const structuredView = useTerminalStore((state) => state.structuredView)
const setStructuredView = useTerminalStore((state) => state.setStructuredView)
const setHasHydrated = useTerminalStore((state) => state.setHasHydrated)
const isExpanded = useTerminalStore(
(state) => state.terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
@@ -565,7 +472,6 @@ export const Terminal = memo(function Terminal() {
const exportConsoleCSV = useTerminalConsoleStore((state) => state.exportConsoleCSV)
const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null)
const [expandedExecutions, setExpandedExecutions] = useState<Set<string>>(new Set())
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
const [isToggling, setIsToggling] = useState(false)
const [showCopySuccess, setShowCopySuccess] = useState(false)
@@ -588,7 +494,6 @@ export const Terminal = memo(function Terminal() {
sortConfig,
toggleBlock,
toggleStatus,
toggleRunId,
toggleSort,
clearFilters,
filterEntries,
@@ -634,7 +539,7 @@ export const Terminal = memo(function Terminal() {
/**
* Navigable block entries for keyboard navigation.
* Only includes actual block outputs (not subflows/iterations/headers).
* Only includes actual block outputs (excludes subflow/iteration container nodes).
* Includes parent node IDs for auto-expanding when navigating.
*/
const navigableEntries = useMemo(() => {
@@ -662,65 +567,6 @@ export const Terminal = memo(function Terminal() {
return Array.from(blocksMap.values()).sort((a, b) => a.blockName.localeCompare(b.blockName))
}, [allWorkflowEntries])
/**
* Get unique run IDs from all workflow entries
*/
const uniqueRunIds = useMemo(() => {
const runIdsSet = new Set<string>()
allWorkflowEntries.forEach((entry) => {
if (entry.executionId) {
runIdsSet.add(entry.executionId)
}
})
return Array.from(runIdsSet).sort()
}, [allWorkflowEntries])
/**
* Track color offset for run IDs
*/
const colorStateRef = useRef<{ executionIds: string[]; offset: number }>({
executionIds: [],
offset: 0,
})
/**
* Compute colors for each execution ID
*/
const executionColorMap = useMemo(() => {
const currentIds: string[] = []
const seen = new Set<string>()
for (let i = allWorkflowEntries.length - 1; i >= 0; i--) {
const execId = allWorkflowEntries[i].executionId
if (execId && !seen.has(execId)) {
currentIds.push(execId)
seen.add(execId)
}
}
const { executionIds: prevIds, offset: prevOffset } = colorStateRef.current
let newOffset = prevOffset
if (prevIds.length > 0 && currentIds.length > 0) {
const currentOldest = currentIds[0]
if (prevIds[0] !== currentOldest) {
const trimmedCount = prevIds.indexOf(currentOldest)
if (trimmedCount > 0) {
newOffset = (prevOffset + trimmedCount) % RUN_ID_COLORS.length
}
}
}
const colorMap = new Map<string, string>()
for (let i = 0; i < currentIds.length; i++) {
const colorIndex = (newOffset + i) % RUN_ID_COLORS.length
colorMap.set(currentIds[i], RUN_ID_COLORS[colorIndex])
}
colorStateRef.current = { executionIds: currentIds, offset: newOffset }
return colorMap
}, [allWorkflowEntries])
/**
* Check if input data exists for selected entry
*/
@@ -784,7 +630,7 @@ export const Terminal = memo(function Terminal() {
}, [allWorkflowEntries.length, expandToLastHeight, openOnRun, isExpanded])
/**
* Auto-expand newest execution, subflows, and iterations when new entries arrive.
* Auto-expand subflows and iterations when new entries arrive.
* This always runs regardless of autoSelectEnabled - new runs should always be visible.
*/
useEffect(() => {
@@ -792,14 +638,6 @@ export const Terminal = memo(function Terminal() {
const newestExec = executionGroups[0]
// Always expand the newest execution group
setExpandedExecutions((prev) => {
if (prev.has(newestExec.executionId)) return prev
const next = new Set(prev)
next.add(newestExec.executionId)
return next
})
// Collect all node IDs that should be expanded (subflows and their iterations)
const nodeIdsToExpand: string[] = []
for (const node of newestExec.entryTree) {
@@ -834,35 +672,20 @@ export const Terminal = memo(function Terminal() {
}, [])
/**
* Handle entry selection
* Handle entry selection - clicking same entry toggles selection off
*/
const handleSelectEntry = useCallback(
(entry: ConsoleEntry) => {
focusTerminal()
setSelectedEntry((prev) => {
const isDeselecting = prev?.id === entry.id
setAutoSelectEnabled(isDeselecting)
return isDeselecting ? null : entry
// Disable auto-select on any manual selection/deselection
setAutoSelectEnabled(false)
return prev?.id === entry.id ? null : entry
})
},
[focusTerminal]
)
/**
* Toggle execution expansion
*/
const handleToggleExecution = useCallback((executionId: string) => {
setExpandedExecutions((prev) => {
const next = new Set(prev)
if (next.has(executionId)) {
next.delete(executionId)
} else {
next.add(executionId)
}
return next
})
}, [])
/**
* Toggle subflow node expansion
*/
@@ -912,7 +735,6 @@ export const Terminal = memo(function Terminal() {
if (activeWorkflowId) {
clearWorkflowConsole(activeWorkflowId)
setSelectedEntry(null)
setExpandedExecutions(new Set())
setExpandedNodes(new Set())
}
}, [activeWorkflowId, clearWorkflowConsole])
@@ -951,14 +773,6 @@ export const Terminal = memo(function Terminal() {
[toggleStatus, closeLogRowMenu]
)
const handleFilterByRunId = useCallback(
(runId: string) => {
toggleRunId(runId)
closeLogRowMenu()
},
[toggleRunId, closeLogRowMenu]
)
const handleCopyRunId = useCallback(
(runId: string) => {
navigator.clipboard.writeText(runId)
@@ -1053,85 +867,57 @@ export const Terminal = memo(function Terminal() {
}
}, [showCopySuccess])
/**
* Scroll the logs container to the bottom.
*/
const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => {
const container = logsContainerRef.current
if (!container) return
container.scrollTop = container.scrollHeight
})
}, [])
/**
* Scroll an entry into view (for keyboard navigation).
*/
const scrollEntryIntoView = useCallback((entryId: string) => {
requestAnimationFrame(() => {
const container = logsContainerRef.current
if (!container) return
const el = container.querySelector(`[data-entry-id="${entryId}"]`)
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
const container = logsContainerRef.current
if (!container) return
const el = container.querySelector(`[data-entry-id="${entryId}"]`)
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}, [])
/**
* Auto-select the last entry (bottom of the list) when new logs arrive.
*/
useEffect(() => {
if (executionGroups.length === 0 || navigableEntries.length === 0) {
setAutoSelectEnabled(true)
setSelectedEntry(null)
prevEntriesLengthRef.current = 0
return
}
if (autoSelectEnabled && navigableEntries.length > prevEntriesLengthRef.current) {
// Get the last entry from the newest execution (it's at the bottom of the list)
const newestExecutionId = executionGroups[0].executionId
let lastNavEntry: NavigableBlockEntry | null = null
if (!autoSelectEnabled) return
for (const navEntry of navigableEntries) {
if (navEntry.executionId === newestExecutionId) {
lastNavEntry = navEntry
} else {
break
}
const newestExecutionId = executionGroups[0].executionId
let lastNavEntry: NavigableBlockEntry | null = null
for (const navEntry of navigableEntries) {
if (navEntry.executionId === newestExecutionId) {
lastNavEntry = navEntry
} else {
break
}
if (!lastNavEntry) {
prevEntriesLengthRef.current = navigableEntries.length
return
}
setSelectedEntry(lastNavEntry.entry)
focusTerminal()
// Expand execution and parent nodes
setExpandedExecutions((prev) => {
if (prev.has(lastNavEntry.executionId)) return prev
const next = new Set(prev)
next.add(lastNavEntry.executionId)
return next
})
if (lastNavEntry.parentNodeIds.length > 0) {
setExpandedNodes((prev) => {
const hasAll = lastNavEntry.parentNodeIds.every((id) => prev.has(id))
if (hasAll) return prev
const next = new Set(prev)
lastNavEntry.parentNodeIds.forEach((id) => next.add(id))
return next
})
}
scrollToBottom()
}
prevEntriesLengthRef.current = navigableEntries.length
}, [executionGroups, navigableEntries, autoSelectEnabled, focusTerminal, scrollToBottom])
if (!lastNavEntry) return
if (selectedEntry?.id === lastNavEntry.entry.id) return
setSelectedEntry(lastNavEntry.entry)
focusTerminal()
if (lastNavEntry.parentNodeIds.length > 0) {
setExpandedNodes((prev) => {
const hasAll = lastNavEntry.parentNodeIds.every((id) => prev.has(id))
if (hasAll) return prev
const next = new Set(prev)
lastNavEntry.parentNodeIds.forEach((id) => next.add(id))
return next
})
}
}, [executionGroups, navigableEntries, autoSelectEnabled, selectedEntry?.id, focusTerminal])
useEffect(() => {
if (selectedEntry) {
scrollEntryIntoView(selectedEntry.id)
}
}, [selectedEntry?.id, scrollEntryIntoView])
/**
* Sync selected entry with latest data from store.
@@ -1173,14 +959,6 @@ export const Terminal = memo(function Terminal() {
setAutoSelectEnabled(false)
setSelectedEntry(navEntry.entry)
// Auto-expand the execution group
setExpandedExecutions((prev) => {
if (prev.has(navEntry.executionId)) return prev
const next = new Set(prev)
next.add(navEntry.executionId)
return next
})
// Auto-expand parent nodes (subflows, iterations)
if (navEntry.parentNodeIds.length > 0) {
setExpandedNodes((prev) => {
@@ -1370,10 +1148,7 @@ export const Terminal = memo(function Terminal() {
filters={filters}
toggleStatus={toggleStatus}
toggleBlock={toggleBlock}
toggleRunId={toggleRunId}
uniqueBlocks={uniqueBlocks}
uniqueRunIds={uniqueRunIds}
executionColorMap={executionColorMap}
hasActiveFilters={hasActiveFilters}
/>
)}
@@ -1448,27 +1223,6 @@ export const Terminal = memo(function Terminal() {
</Tooltip.Root>
)}
{hasActiveFilters && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
clearFilters()
}}
aria-label='Clear filters'
className='!p-1.5 -m-1.5'
>
<FilterX className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Clear filters</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{filteredEntries.length > 0 && (
<>
<Tooltip.Root>
@@ -1557,12 +1311,11 @@ export const Terminal = memo(function Terminal() {
No logs yet
</div>
) : (
executionGroups.map((group) => (
<ExecutionRow
executionGroups.map((group, index) => (
<ExecutionGroupRow
key={group.executionId}
group={group}
isExpanded={expandedExecutions.has(group.executionId)}
onToggle={() => handleToggleExecution(group.executionId)}
showSeparator={index > 0}
selectedEntryId={selectedEntry?.id || null}
onSelectEntry={handleSelectEntry}
expandedNodes={expandedNodes}
@@ -1593,7 +1346,6 @@ export const Terminal = memo(function Terminal() {
filteredEntries={filteredEntries}
handleExportConsole={handleExportConsole}
hasActiveFilters={hasActiveFilters}
clearFilters={clearFilters}
handleClearConsole={handleClearConsole}
shouldShowCodeDisplay={shouldShowCodeDisplay}
outputDataStringified={outputDataStringified}
@@ -1602,10 +1354,7 @@ export const Terminal = memo(function Terminal() {
filters={filters}
toggleBlock={toggleBlock}
toggleStatus={toggleStatus}
toggleRunId={toggleRunId}
uniqueBlocks={uniqueBlocks}
uniqueRunIds={uniqueRunIds}
executionColorMap={executionColorMap}
/>
)}
</div>
@@ -1621,15 +1370,9 @@ export const Terminal = memo(function Terminal() {
filters={filters}
onFilterByBlock={handleFilterByBlock}
onFilterByStatus={handleFilterByStatus}
onFilterByRunId={handleFilterByRunId}
onCopyRunId={handleCopyRunId}
onClearFilters={() => {
clearFilters()
closeLogRowMenu()
}}
onClearConsole={handleClearConsoleFromMenu}
onFixInCopilot={handleFixInCopilot}
hasActiveFilters={hasActiveFilters}
/>
</>
)

View File

@@ -1,15 +1,9 @@
'use client'
import { memo } from 'react'
import { Badge } from '@/components/emcn'
/**
* Terminal filter configuration state
*/
export interface TerminalFilters {
blockIds: Set<string>
statuses: Set<'error' | 'info'>
runIds: Set<string>
}
/**
@@ -67,45 +61,4 @@ export const ROW_STYLES = {
/**
* Common badge styling for status badges
*/
export const BADGE_STYLES = {
base: 'rounded-[4px] px-[4px] py-[0px] text-[11px]',
mono: 'rounded-[4px] px-[4px] py-[0px] font-mono text-[11px]',
} as const
/**
* Running badge component - displays a consistent "Running" indicator
*/
export const RunningBadge = memo(function RunningBadge() {
return (
<Badge variant='green' className={BADGE_STYLES.base}>
Running
</Badge>
)
})
/**
* Props for StatusDisplay component
*/
export interface StatusDisplayProps {
isRunning: boolean
isCanceled: boolean
formattedDuration: string
}
/**
* Reusable status display for terminal rows.
* Shows Running badge, 'canceled' text, or formatted duration.
*/
export const StatusDisplay = memo(function StatusDisplay({
isRunning,
isCanceled,
formattedDuration,
}: StatusDisplayProps) {
if (isRunning) {
return <RunningBadge />
}
if (isCanceled) {
return <>canceled</>
}
return <>{formattedDuration}</>
})
export const BADGE_STYLE = 'rounded-[4px] px-[4px] py-[0px] text-[11px]'

View File

@@ -1,8 +1,7 @@
'use client'
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'
/**
@@ -13,20 +12,6 @@ const SUBFLOW_COLORS = {
parallel: '#FEE12B',
} as const
/**
* Run ID color palette for visual distinction between executions
*/
export const RUN_ID_COLORS = [
'#4ADE80', // Green
'#F472B6', // Pink
'#60C5FF', // Blue
'#FF8533', // Orange
'#C084FC', // Purple
'#EAB308', // Yellow
'#2DD4BF', // Teal
'#FB7185', // Rose
] as const
/**
* Retrieves the icon component for a given block type
*/
@@ -77,25 +62,6 @@ export function formatDuration(ms?: number): string {
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Truncates execution ID for display as run ID
*/
export function formatRunId(executionId?: string): string {
if (!executionId) return '-'
return executionId.slice(0, 8)
}
/**
* Gets color for a run ID from the precomputed color map
*/
export function getRunIdColor(
executionId: string | undefined,
colorMap: Map<string, string>
): string | null {
if (!executionId) return null
return colorMap.get(executionId) ?? null
}
/**
* Determines if a keyboard event originated from a text-editable element
*/
@@ -476,13 +442,11 @@ export function flattenBlockEntriesOnly(
return result
}
// BlockInfo is now in types.ts for shared use across terminal components
/**
* Terminal height configuration constants
*/
export const TERMINAL_CONFIG = {
NEAR_MIN_THRESHOLD: 40,
BLOCK_COLUMN_WIDTH_PX: 240,
BLOCK_COLUMN_WIDTH_PX: TERMINAL_BLOCK_COLUMN_WIDTH,
HEADER_TEXT_CLASS: 'font-medium text-[var(--text-tertiary)] text-[12px]',
} as const

View File

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