From 86653cadb5d586e7cde35262dc8f5389fcfe45da Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Wed, 28 Jan 2026 00:53:45 -0800 Subject: [PATCH] feat(terminal): log view --- .../components/trace-spans/trace-spans.tsx | 16 +- .../connection-blocks/connection-blocks.tsx | 8 +- .../filter-popover/filter-popover.tsx | 160 ++ .../components/filter-popover/index.ts | 1 + .../components/terminal/components/index.ts | 6 +- .../components/log-row-context-menu/index.ts | 1 + .../log-row-context-menu.tsx | 23 +- .../components/output-context-menu.tsx | 14 +- .../components/structured-output.tsx | 215 +-- .../terminal/components/output-panel/index.ts | 2 + .../components/output-panel/output-panel.tsx | 255 +-- .../components/toggle-button/index.ts | 1 + .../toggle-button/toggle-button.tsx | 33 + .../components/terminal/hooks/index.ts | 1 + .../terminal/hooks/use-terminal-filters.ts | 24 +- .../components/terminal/terminal.tsx | 1528 ++++++++++------- .../components/terminal/types.tsx | 111 ++ .../[workflowId]/components/terminal/utils.ts | 488 ++++++ .../hooks/use-workflow-execution.ts | 107 +- .../components/emcn/components/code/code.tsx | 16 +- apps/sim/stores/terminal/console/store.ts | 37 + apps/sim/stores/terminal/console/types.ts | 13 + 22 files changed, 2108 insertions(+), 952 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/index.ts rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/{ => log-row-context-menu}/log-row-context-menu.tsx (93%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx index dab65614c..c5988dc23 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx @@ -573,7 +573,19 @@ const TraceSpanNode = memo(function TraceSpanNode({ return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime)) }, [span, spanId, spanStartTime]) - const hasChildren = allChildren.length > 0 + // Hide empty model timing segments for agents without tool calls + const filteredChildren = useMemo(() => { + const isAgent = span.type?.toLowerCase() === 'agent' + const hasToolCalls = + (span.toolCalls?.length ?? 0) > 0 || allChildren.some((c) => c.type?.toLowerCase() === 'tool') + + if (isAgent && !hasToolCalls) { + return allChildren.filter((c) => c.type?.toLowerCase() !== 'model') + } + return allChildren + }, [allChildren, span.type, span.toolCalls]) + + const hasChildren = filteredChildren.length > 0 const isExpanded = isRootWorkflow || expandedNodes.has(spanId) const isToggleable = !isRootWorkflow @@ -685,7 +697,7 @@ const TraceSpanNode = memo(function TraceSpanNode({ {/* Nested Children */} {hasChildren && (
- {allChildren.map((child, index) => ( + {filteredChildren.map((child, index) => (
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx new file mode 100644 index 000000000..a0312bf5f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx @@ -0,0 +1,160 @@ +'use client' + +import { memo } from 'react' +import clsx from 'clsx' +import { Filter } from 'lucide-react' +import { + Button, + Popover, + PopoverContent, + PopoverDivider, + PopoverItem, + PopoverScrollArea, + PopoverSection, + PopoverTrigger, +} from '@/components/emcn' +import type { + BlockInfo, + TerminalFilters, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' +import { + formatRunId, + getBlockIcon, + getRunIdColor, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' + +/** + * Props for the FilterPopover component + */ +export interface FilterPopoverProps { + open: boolean + onOpenChange: (open: boolean) => void + filters: TerminalFilters + toggleStatus: (status: 'error' | 'info') => void + toggleBlock: (blockId: string) => void + toggleRunId: (runId: string) => void + uniqueBlocks: BlockInfo[] + uniqueRunIds: string[] + executionColorMap: Map + hasActiveFilters: boolean +} + +/** + * Filter popover component used in terminal header and output panel + */ +export const FilterPopover = memo(function FilterPopover({ + open, + onOpenChange, + filters, + toggleStatus, + toggleBlock, + toggleRunId, + uniqueBlocks, + uniqueRunIds, + executionColorMap, + hasActiveFilters, +}: FilterPopoverProps) { + return ( + + + + + e.stopPropagation()} + minWidth={160} + maxWidth={220} + maxHeight={300} + > + Status + toggleStatus('error')} + > +
+ Error + + toggleStatus('info')} + > +
+ Info + + + {uniqueBlocks.length > 0 && ( + <> + + Blocks + + {uniqueBlocks.map((block) => { + const BlockIcon = getBlockIcon(block.blockType) + const isSelected = filters.blockIds.has(block.blockId) + + return ( + toggleBlock(block.blockId)} + > + {BlockIcon && } + {block.blockName} + + ) + })} + + + )} + + {uniqueRunIds.length > 0 && ( + <> + + Run ID + + {uniqueRunIds.map((runId) => { + const isSelected = filters.runIds.has(runId) + const runIdColor = getRunIdColor(runId, executionColorMap) + + return ( + toggleRunId(runId)} + > + + {formatRunId(runId)} + + + ) + })} + + + )} + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/index.ts new file mode 100644 index 000000000..3804f73bb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/index.ts @@ -0,0 +1 @@ +export { FilterPopover, type FilterPopoverProps } from './filter-popover' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts index 45411b9c9..60a203827 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts @@ -1,2 +1,4 @@ -export { LogRowContextMenu } from './log-row-context-menu' -export { OutputPanel } from './output-panel' +export { FilterPopover, type FilterPopoverProps } from './filter-popover' +export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu' +export { OutputPanel, type OutputPanelProps } from './output-panel' +export { ToggleButton, type ToggleButtonProps } from './toggle-button' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/index.ts new file mode 100644 index 000000000..98d5a2b6b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/index.ts @@ -0,0 +1 @@ +export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx similarity index 93% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx index ff4fd71d1..b65b5ab76 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx @@ -1,6 +1,6 @@ 'use client' -import type { RefObject } from 'react' +import { memo, type RefObject } from 'react' import { Popover, PopoverAnchor, @@ -8,20 +8,13 @@ import { PopoverDivider, PopoverItem, } from '@/components/emcn' +import type { + ContextMenuPosition, + TerminalFilters, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' import type { ConsoleEntry } from '@/stores/terminal' -interface ContextMenuPosition { - x: number - y: number -} - -interface TerminalFilters { - blockIds: Set - statuses: Set<'error' | 'info'> - runIds: Set -} - -interface LogRowContextMenuProps { +export interface LogRowContextMenuProps { isOpen: boolean position: ContextMenuPosition menuRef: RefObject @@ -42,7 +35,7 @@ interface LogRowContextMenuProps { * Context menu for terminal log rows (left side). * Displays filtering options based on the selected row's properties. */ -export function LogRowContextMenu({ +export const LogRowContextMenu = memo(function LogRowContextMenu({ isOpen, position, menuRef, @@ -173,4 +166,4 @@ export function LogRowContextMenu({ ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx index d3172d170..0b3288cda 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx @@ -1,6 +1,6 @@ 'use client' -import type { RefObject } from 'react' +import { memo, type RefObject } from 'react' import { Popover, PopoverAnchor, @@ -8,13 +8,9 @@ import { PopoverDivider, PopoverItem, } from '@/components/emcn' +import type { ContextMenuPosition } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' -interface ContextMenuPosition { - x: number - y: number -} - -interface OutputContextMenuProps { +export interface OutputContextMenuProps { isOpen: boolean position: ContextMenuPosition menuRef: RefObject @@ -36,7 +32,7 @@ interface OutputContextMenuProps { * Context menu for terminal output panel (right side). * Displays copy, search, and display options for the code viewer. */ -export function OutputContextMenu({ +export const OutputContextMenu = memo(function OutputContextMenu({ isOpen, position, menuRef, @@ -123,4 +119,4 @@ export function OutputContextMenu({ ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx index 5f2c96faa..678dd6a4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx @@ -1,9 +1,17 @@ 'use client' import type React from 'react' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ChevronDown } from 'lucide-react' -import { Badge } from '@/components/emcn' +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { Badge, ChevronDown } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' type ValueType = 'null' | 'undefined' | 'array' | 'string' | 'number' | 'boolean' | 'object' @@ -15,13 +23,19 @@ interface NodeEntry { path: string } -/** Search context passed through the component tree */ -interface SearchContext { +/** + * Search context for the structured output tree. + * Separates stable values (query, pathToMatchIndices) from frequently changing currentMatchIndex + * to avoid unnecessary re-renders of the entire tree. + */ +interface SearchContextValue { query: string - currentMatchIndex: number pathToMatchIndices: Map + currentMatchIndexRef: React.RefObject } +const SearchContext = createContext(null) + const BADGE_VARIANTS: Record = { string: 'green', number: 'blue', @@ -33,16 +47,17 @@ const BADGE_VARIANTS: Record = { } as const const STYLES = { - row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]', + row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] rounded-[8px] px-[6px] -mx-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]', chevron: 'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]', keyName: - 'font-medium text-[13px] text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]', - badge: 'rounded-[4px] px-[4px] py-[0px] font-mono text-[11px]', - summary: 'font-mono text-[12px] text-[var(--text-tertiary)]', - indent: 'ml-[3px] border-[var(--border)] border-l pl-[9px]', - value: 'py-[2px] font-mono text-[13px] text-[var(--text-secondary)]', - emptyValue: 'py-[2px] font-mono text-[13px] text-[var(--text-tertiary)]', + 'font-medium text-[13px] text-[var(--text-primary)] group-hover:text-[var(--text-primary)]', + badge: 'rounded-[4px] px-[4px] py-[0px] text-[11px]', + summary: 'text-[12px] text-[var(--text-tertiary)]', + indent: + 'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]', + value: 'py-[2px] text-[13px] text-[var(--text-primary)]', + emptyValue: 'py-[2px] text-[13px] text-[var(--text-tertiary)]', matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40', currentMatchHighlight: 'bg-orange-400', } as const @@ -51,8 +66,6 @@ const EMPTY_MATCH_INDICES: number[] = [] /** * Returns the type label for a value - * @param value - The value to get the type label for - * @returns The type label string */ function getTypeLabel(value: unknown): ValueType { if (value === null) return 'null' @@ -63,8 +76,6 @@ function getTypeLabel(value: unknown): ValueType { /** * Formats a primitive value for display - * @param value - The primitive value to format - * @returns The formatted string representation */ function formatPrimitive(value: unknown): string { if (value === null) return 'null' @@ -74,8 +85,6 @@ function formatPrimitive(value: unknown): string { /** * Checks if a value is a primitive (not object/array) - * @param value - The value to check - * @returns True if the value is a primitive */ function isPrimitive(value: unknown): value is null | undefined | string | number | boolean { return value === null || value === undefined || typeof value !== 'object' @@ -83,8 +92,6 @@ function isPrimitive(value: unknown): value is null | undefined | string | numbe /** * Checks if a value is an empty object or array - * @param value - The value to check - * @returns True if the value is empty */ function isEmpty(value: unknown): boolean { if (Array.isArray(value)) return value.length === 0 @@ -94,8 +101,6 @@ function isEmpty(value: unknown): boolean { /** * Extracts error message from various error data formats - * @param data - The error data to extract message from - * @returns The extracted error message string */ function extractErrorMessage(data: unknown): string { if (typeof data === 'string') return data @@ -108,9 +113,6 @@ function extractErrorMessage(data: unknown): string { /** * Builds node entries from an object or array value - * @param value - The object or array to build entries from - * @param basePath - The base path for constructing child paths - * @returns Array of node entries */ function buildEntries(value: unknown, basePath: string): NodeEntry[] { if (Array.isArray(value)) { @@ -125,8 +127,6 @@ function buildEntries(value: unknown, basePath: string): NodeEntry[] { /** * Gets the count summary for collapsed arrays/objects - * @param value - The array or object to summarize - * @returns Summary string or null for primitives */ function getCollapsedSummary(value: unknown): string | null { if (Array.isArray(value)) { @@ -142,9 +142,6 @@ function getCollapsedSummary(value: unknown): string | null { /** * Computes initial expanded paths for first-level items - * @param data - The data to compute paths for - * @param isError - Whether this is error data - * @returns Set of initially expanded paths */ function computeInitialPaths(data: unknown, isError: boolean): Set { if (isError) return new Set(['root.error']) @@ -157,8 +154,6 @@ function computeInitialPaths(data: unknown, isError: boolean): Set { /** * Gets all ancestor paths needed to reach a given path - * @param path - The target path - * @returns Array of ancestor paths */ function getAncestorPaths(path: string): string[] { const ancestors: string[] = [] @@ -176,9 +171,6 @@ function getAncestorPaths(path: string): string[] { /** * Finds all case-insensitive matches of a query within text - * @param text - The text to search in - * @param query - The search query - * @returns Array of [startIndex, endIndex] tuples */ function findTextMatches(text: string, query: string): Array<[number, number]> { if (!query) return [] @@ -200,10 +192,6 @@ function findTextMatches(text: string, query: string): Array<[number, number]> { /** * Adds match entries for a primitive value at the given path - * @param value - The primitive value - * @param path - The path to this value - * @param query - The search query - * @param matches - The matches array to add to */ function addPrimitiveMatches(value: unknown, path: string, query: string, matches: string[]): void { const text = formatPrimitive(value) @@ -215,10 +203,6 @@ function addPrimitiveMatches(value: unknown, path: string, query: string, matche /** * Recursively collects all match paths across the entire data tree - * @param data - The data to search - * @param query - The search query - * @param basePath - The base path for this level - * @returns Array of paths where matches were found */ function collectAllMatchPaths(data: unknown, query: string, basePath: string): string[] { if (!query) return [] @@ -243,8 +227,6 @@ function collectAllMatchPaths(data: unknown, query: string, basePath: string): s /** * Builds a map from path to array of global match indices - * @param matchPaths - Array of paths where matches occur - * @returns Map from path to array of global indices */ function buildPathToIndicesMap(matchPaths: string[]): Map { const map = new Map() @@ -261,25 +243,28 @@ function buildPathToIndicesMap(matchPaths: string[]): Map { interface HighlightedTextProps { text: string - searchQuery: string | undefined matchIndices: number[] - currentMatchIndex: number + path: string } /** - * Renders text with search highlights + * Renders text with search highlights. + * Uses context to access search state and avoid prop drilling. */ const HighlightedText = memo(function HighlightedText({ text, - searchQuery, matchIndices, - currentMatchIndex, + path, }: HighlightedTextProps) { - if (!searchQuery || matchIndices.length === 0) return <>{text} + const searchContext = useContext(SearchContext) - const textMatches = findTextMatches(text, searchQuery) + if (!searchContext || matchIndices.length === 0) return <>{text} + + const textMatches = findTextMatches(text, searchContext.query) if (textMatches.length === 0) return <>{text} + const currentMatchIndex = searchContext.currentMatchIndexRef.current + const segments: React.ReactNode[] = [] let lastEnd = 0 @@ -288,12 +273,12 @@ const HighlightedText = memo(function HighlightedText({ const isCurrent = globalIndex === currentMatchIndex if (start > lastEnd) { - segments.push({text.slice(lastEnd, start)}) + segments.push({text.slice(lastEnd, start)}) } segments.push( {text.slice(lastEnd)}) + segments.push({text.slice(lastEnd)}) } return <>{segments} @@ -322,11 +307,11 @@ interface StructuredNodeProps { onToggle: (path: string) => void wrapText: boolean isError?: boolean - searchContext?: SearchContext } /** - * Recursive node component for rendering structured data + * Recursive node component for rendering structured data. + * Uses context for search state to avoid re-renders when currentMatchIndex changes. */ const StructuredNode = memo(function StructuredNode({ name, @@ -336,8 +321,8 @@ const StructuredNode = memo(function StructuredNode({ onToggle, wrapText, isError = false, - searchContext, }: StructuredNodeProps) { + const searchContext = useContext(SearchContext) const type = getTypeLabel(value) const isPrimitiveValue = isPrimitive(value) const isEmptyValue = !isPrimitiveValue && isEmpty(value) @@ -379,7 +364,6 @@ const StructuredNode = memo(function StructuredNode({ tabIndex={0} aria-expanded={isExpanded} > - {name} {type} @@ -387,6 +371,7 @@ const StructuredNode = memo(function StructuredNode({ {!isExpanded && collapsedSummary && ( {collapsedSummary} )} +
{isExpanded && ( @@ -398,12 +383,7 @@ const StructuredNode = memo(function StructuredNode({ wrapText ? '[word-break:break-word]' : 'whitespace-nowrap' )} > - +
) : isEmptyValue ? (
{Array.isArray(value) ? '[]' : '{}'}
@@ -417,7 +397,6 @@ const StructuredNode = memo(function StructuredNode({ expandedPaths={expandedPaths} onToggle={onToggle} wrapText={wrapText} - searchContext={searchContext} /> )) )} @@ -427,10 +406,11 @@ const StructuredNode = memo(function StructuredNode({ ) }) -interface StructuredOutputProps { +export interface StructuredOutputProps { data: unknown wrapText?: boolean isError?: boolean + isRunning?: boolean className?: string searchQuery?: string currentMatchIndex?: number @@ -441,11 +421,13 @@ interface StructuredOutputProps { /** * Renders structured data as nested collapsible blocks. * Supports search with highlighting, auto-expand, and scroll-to-match. + * Uses React Context for search state to prevent re-render cascade. */ export const StructuredOutput = memo(function StructuredOutput({ data, wrapText = true, isError = false, + isRunning = false, className, searchQuery, currentMatchIndex = 0, @@ -458,6 +440,16 @@ export const StructuredOutput = memo(function StructuredOutput({ const prevDataRef = useRef(data) const prevIsErrorRef = useRef(isError) const internalRef = useRef(null) + const currentMatchIndexRef = useRef(currentMatchIndex) + + // Keep ref in sync + currentMatchIndexRef.current = currentMatchIndex + + // Force re-render of highlighted text when currentMatchIndex changes + const [, forceUpdate] = useState(0) + useEffect(() => { + forceUpdate((n) => n + 1) + }, [currentMatchIndex]) const setContainerRef = useCallback( (node: HTMLDivElement | null) => { @@ -545,44 +537,73 @@ export const StructuredOutput = memo(function StructuredOutput({ return buildEntries(data, 'root') }, [data]) - const searchContext = useMemo(() => { - if (!searchQuery) return undefined - return { query: searchQuery, currentMatchIndex, pathToMatchIndices } - }, [searchQuery, currentMatchIndex, pathToMatchIndices]) + // Create stable search context value - only changes when query or pathToMatchIndices change + const searchContextValue = useMemo(() => { + if (!searchQuery) return null + return { + query: searchQuery, + pathToMatchIndices, + currentMatchIndexRef, + } + }, [searchQuery, pathToMatchIndices]) const containerClass = cn('flex flex-col pl-[20px]', className) - if (isError) { + // Show "Running" badge when running with undefined data + if (isRunning && data === undefined) { return (
- +
+ running + + Running + +
+
+ ) + } + + if (isError) { + return ( + +
+ +
+
+ ) + } + + if (rootEntries.length === 0) { + return ( +
+ null
) } return ( -
- {rootEntries.map((entry) => ( - - ))} -
+ +
+ {rootEntries.map((entry) => ( + + ))} +
+
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts index 38a8c8db6..20fe06c25 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts @@ -1,2 +1,4 @@ +export { OutputContextMenu, type OutputContextMenuProps } from './components/output-context-menu' +export { StructuredOutput, type StructuredOutputProps } from './components/structured-output' export type { OutputPanelProps } from './output-panel' export { OutputPanel } from './output-panel' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx index fdf7358bd..7fbeb7329 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -1,13 +1,12 @@ 'use client' -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { ArrowDown, ArrowDownToLine, ArrowUp, Check, - ChevronDown, Clipboard, Database, FilterX, @@ -29,11 +28,18 @@ import { PopoverTrigger, Tooltip, } from '@/components/emcn' +import { FilterPopover } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover' import { OutputContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu' import { StructuredOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output' +import { ToggleButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button' +import type { + BlockInfo, + TerminalFilters, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import type { ConsoleEntry } from '@/stores/terminal' +import { useTerminalStore } from '@/stores/terminal' interface OutputCodeContentProps { code: string @@ -73,32 +79,13 @@ const OutputCodeContent = React.memo(function OutputCodeContent({ ) }) -/** - * Reusable toggle button component - */ -const ToggleButton = ({ - isExpanded, - onClick, -}: { - isExpanded: boolean - onClick: (e: React.MouseEvent) => void -}) => ( - -) - /** * Props for the OutputPanel component + * Store-backed settings (wrapText, openOnRun, structuredView, outputPanelWidth) + * are accessed directly from useTerminalStore to reduce prop drilling. */ export interface OutputPanelProps { selectedEntry: ConsoleEntry - outputPanelWidth: number handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void handleHeaderClick: () => void isExpanded: boolean @@ -117,26 +104,25 @@ export interface OutputPanelProps { hasActiveFilters: boolean clearFilters: () => void handleClearConsole: (e: React.MouseEvent) => void - wrapText: boolean - setWrapText: (wrap: boolean) => void - openOnRun: boolean - setOpenOnRun: (open: boolean) => void - structuredView: boolean - setStructuredView: (structured: boolean) => void - outputOptionsOpen: boolean - setOutputOptionsOpen: (open: boolean) => void shouldShowCodeDisplay: boolean outputDataStringified: string outputData: unknown handleClearConsoleFromMenu: () => void + filters: TerminalFilters + toggleBlock: (blockId: string) => void + toggleStatus: (status: 'error' | 'info') => void + toggleRunId: (runId: string) => void + uniqueBlocks: BlockInfo[] + uniqueRunIds: string[] + executionColorMap: Map } /** * Output panel component that manages its own search state. + * Accesses store-backed settings directly to reduce prop drilling. */ export const OutputPanel = React.memo(function OutputPanel({ selectedEntry, - outputPanelWidth, handleOutputPanelResizeMouseDown, handleHeaderClick, isExpanded, @@ -155,20 +141,30 @@ export const OutputPanel = React.memo(function OutputPanel({ hasActiveFilters, clearFilters, handleClearConsole, - wrapText, - setWrapText, - openOnRun, - setOpenOnRun, - structuredView, - setStructuredView, - outputOptionsOpen, - setOutputOptionsOpen, shouldShowCodeDisplay, outputDataStringified, outputData, handleClearConsoleFromMenu, + filters, + toggleBlock, + toggleStatus, + toggleRunId, + uniqueBlocks, + uniqueRunIds, + executionColorMap, }: OutputPanelProps) { + // Access store-backed settings directly to reduce prop drilling + const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth) + const wrapText = useTerminalStore((state) => state.wrapText) + const setWrapText = useTerminalStore((state) => state.setWrapText) + const openOnRun = useTerminalStore((state) => state.openOnRun) + const setOpenOnRun = useTerminalStore((state) => state.setOpenOnRun) + const structuredView = useTerminalStore((state) => state.structuredView) + const setStructuredView = useTerminalStore((state) => state.setStructuredView) + const outputContentRef = useRef(null) + const [filtersOpen, setFiltersOpen] = useState(false) + const [outputOptionsOpen, setOutputOptionsOpen] = useState(false) const { isSearchActive: isOutputSearchActive, searchQuery: outputSearchQuery, @@ -215,6 +211,81 @@ export const OutputPanel = React.memo(function OutputPanel({ } }, [storedSelectionText]) + // Memoized callbacks to avoid inline arrow functions + const handleToggleStructuredView = useCallback(() => { + setStructuredView(!structuredView) + }, [structuredView, setStructuredView]) + + const handleToggleWrapText = useCallback(() => { + setWrapText(!wrapText) + }, [wrapText, setWrapText]) + + const handleToggleOpenOnRun = useCallback(() => { + setOpenOnRun(!openOnRun) + }, [openOnRun, setOpenOnRun]) + + const handleClearFiltersClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + clearFilters() + }, + [clearFilters] + ) + + const handleCopyClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + handleCopy() + }, + [handleCopy] + ) + + const handleSearchClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + activateOutputSearch() + }, + [activateOutputSearch] + ) + + const handleCloseSearchClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + closeOutputSearch() + }, + [closeOutputSearch] + ) + + const handleOutputButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!isExpanded) { + expandToLastHeight() + } + if (showInput) setShowInput(false) + }, + [isExpanded, expandToLastHeight, showInput, setShowInput] + ) + + const handleInputButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!isExpanded) { + expandToLastHeight() + } + setShowInput(true) + }, + [isExpanded, expandToLastHeight, setShowInput] + ) + + const handleToggleButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + handleHeaderClick() + }, + [handleHeaderClick] + ) + /** * Track text selection state for context menu. * Skip updates when the context menu is open to prevent the selection @@ -232,6 +303,12 @@ export const OutputPanel = React.memo(function OutputPanel({ return () => document.removeEventListener('selectionchange', handleSelectionChange) }, [isOutputMenuOpen]) + // Memoize the search query for structured output to avoid re-renders + const structuredSearchQuery = useMemo( + () => (isOutputSearchActive ? outputSearchQuery : undefined), + [isOutputSearchActive, outputSearchQuery] + ) + return ( <>
{ - e.stopPropagation() - if (!isExpanded) { - expandToLastHeight() - } - if (showInput) setShowInput(false) - }} + onClick={handleOutputButtonClick} aria-label='Show output' > Output @@ -277,13 +348,7 @@ export const OutputPanel = React.memo(function OutputPanel({ 'px-[8px] py-[6px] text-[12px]', showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]' )} - onClick={(e) => { - e.stopPropagation() - if (!isExpanded) { - expandToLastHeight() - } - setShowInput(true) - }} + onClick={handleInputButtonClick} aria-label='Show input' > Input @@ -291,16 +356,29 @@ export const OutputPanel = React.memo(function OutputPanel({ )}
+ {/* Unified filter popover */} + {filteredEntries.length > 0 && ( + + )} + {isOutputSearchActive ? (
@@ -578,7 +626,7 @@ export const OutputPanel = React.memo(function OutputPanel({ code={selectedEntry.input.code} language={(selectedEntry.input.language as 'javascript' | 'json') || 'javascript'} wrapText={wrapText} - searchQuery={isOutputSearchActive ? outputSearchQuery : undefined} + searchQuery={structuredSearchQuery} currentMatchIndex={currentMatchIndex} onMatchCountChange={handleMatchCountChange} contentRef={outputContentRef} @@ -588,8 +636,9 @@ export const OutputPanel = React.memo(function OutputPanel({ data={outputData} wrapText={wrapText} isError={!showInput && Boolean(selectedEntry.error)} + isRunning={!showInput && Boolean(selectedEntry.isRunning)} className='min-h-full' - searchQuery={isOutputSearchActive ? outputSearchQuery : undefined} + searchQuery={structuredSearchQuery} currentMatchIndex={currentMatchIndex} onMatchCountChange={handleMatchCountChange} contentRef={outputContentRef} @@ -599,7 +648,7 @@ export const OutputPanel = React.memo(function OutputPanel({ code={outputDataStringified} language='json' wrapText={wrapText} - searchQuery={isOutputSearchActive ? outputSearchQuery : undefined} + searchQuery={structuredSearchQuery} currentMatchIndex={currentMatchIndex} onMatchCountChange={handleMatchCountChange} contentRef={outputContentRef} @@ -618,11 +667,11 @@ export const OutputPanel = React.memo(function OutputPanel({ onCopyAll={handleCopy} onSearch={activateOutputSearch} structuredView={structuredView} - onToggleStructuredView={() => setStructuredView(!structuredView)} + onToggleStructuredView={handleToggleStructuredView} wrapText={wrapText} - onToggleWrap={() => setWrapText(!wrapText)} + onToggleWrap={handleToggleWrapText} openOnRun={openOnRun} - onToggleOpenOnRun={() => setOpenOnRun(!openOnRun)} + onToggleOpenOnRun={handleToggleOpenOnRun} onClearConsole={handleClearConsoleFromMenu} hasSelection={hasSelection} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/index.ts new file mode 100644 index 000000000..53f376c1f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/index.ts @@ -0,0 +1 @@ +export { ToggleButton, type ToggleButtonProps } from './toggle-button' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx new file mode 100644 index 000000000..43b2c9dc3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx @@ -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 ( + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/index.ts index 0043dd896..adf2b1607 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/index.ts @@ -1,3 +1,4 @@ +export type { SortConfig, SortDirection, SortField, TerminalFilters } from '../types' export { useOutputPanelResize } from './use-output-panel-resize' export { useTerminalFilters } from './use-terminal-filters' export { useTerminalResize } from './use-terminal-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts index 499af0f73..e5e611927 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts @@ -1,26 +1,10 @@ import { useCallback, useMemo, useState } from 'react' +import type { + SortConfig, + TerminalFilters, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' import type { ConsoleEntry } from '@/stores/terminal' -/** - * Sort configuration - */ -export type SortField = 'timestamp' -export type SortDirection = 'asc' | 'desc' - -export interface SortConfig { - field: SortField - direction: SortDirection -} - -/** - * Filter configuration state - */ -export interface TerminalFilters { - blockIds: Set - statuses: Set<'error' | 'info'> - runIds: Set -} - /** * Custom hook to manage terminal filters and sorting. * Provides filter state, sort state, and filtering/sorting logic for console entries. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 678a9f041..49b78c9fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -7,44 +7,58 @@ import { ArrowDown, ArrowDownToLine, ArrowUp, - ChevronDown, Database, - Filter, FilterX, MoreHorizontal, Palette, Pause, - RepeatIcon, - SplitIcon, Trash2, } from 'lucide-react' import Link from 'next/link' -import { useShallow } from 'zustand/react/shallow' import { Badge, Button, + ChevronDown, Popover, PopoverContent, PopoverItem, - PopoverScrollArea, PopoverTrigger, Tooltip, } from '@/components/emcn' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { formatTimeWithSeconds } from '@/lib/core/utils/formatting' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { + FilterPopover, LogRowContextMenu, OutputPanel, + ToggleButton, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components' import { useOutputPanelResize, 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 { + 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' -import { getBlock } from '@/blocks' import { useShowTrainingControls } from '@/hooks/queries/general-settings' import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants' import { useCopilotTrainingStore } from '@/stores/copilot-training/store' @@ -57,197 +71,474 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' * Terminal height configuration constants */ const MIN_HEIGHT = TERMINAL_HEIGHT.MIN -const NEAR_MIN_THRESHOLD = 40 const DEFAULT_EXPANDED_HEIGHT = TERMINAL_HEIGHT.DEFAULT - -/** - * Column width constants - numeric values for calculations - */ -const BLOCK_COLUMN_WIDTH_PX = 240 const MIN_OUTPUT_PANEL_WIDTH_PX = OUTPUT_PANEL_WIDTH.MIN /** - * Column width constants - Tailwind classes for styling + * Block row component for displaying actual block entries */ -const COLUMN_WIDTHS = { - BLOCK: 'w-[240px]', - STATUS: 'w-[120px]', - DURATION: 'w-[120px]', - RUN_ID: 'w-[120px]', - TIMESTAMP: 'w-[120px]', - OUTPUT_PANEL: 'w-[400px]', -} as const - -/** - * Shared styling constants - */ -const HEADER_TEXT_CLASS = 'font-medium text-[var(--text-tertiary)] text-[12px]' -const ROW_TEXT_CLASS = 'font-medium text-[var(--text-primary)] text-[12px]' -const COLUMN_BASE_CLASS = 'flex-shrink-0' - -/** - * Retrieves the icon component for a given block type - * @param blockType - The block type to get the icon for - * @returns The icon component or null if not found - */ -const 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 -} - -/** - * Formats duration from milliseconds to readable format - */ -const formatDuration = (ms?: number): string => { - if (ms === undefined || ms === null) return '-' - if (ms < 1000) return `${ms}ms` - return `${(ms / 1000).toFixed(2)}s` -} - -/** - * Determines if an entry should show a status badge and which type - */ -const getStatusInfo = ( - success?: boolean, - error?: string | Error | null -): { isError: boolean; label: string } | null => { - if (error) return { isError: true, label: 'Error' } - if (success === undefined) return null - return { isError: !success, label: success ? 'Info' : 'Error' } -} - -/** - * Reusable column header component with optional filter button - */ -const ColumnHeader = ({ - label, - width, - filterButton, +const BlockRow = memo(function BlockRow({ + entry, + isSelected, + onSelect, }: { - label: string - width: string - filterButton?: React.ReactNode -}) => ( -
- {label} - {filterButton &&
{filterButton}
} -
-) + entry: ConsoleEntry + isSelected: boolean + onSelect: (entry: ConsoleEntry) => void +}) { + const BlockIcon = getBlockIcon(entry.blockType) + const hasError = Boolean(entry.error) + const isRunning = Boolean(entry.isRunning) + const isCanceled = Boolean(entry.isCanceled) + const bgColor = getBlockColor(entry.blockType) -/** - * Reusable toggle button component - */ -const ToggleButton = ({ - isExpanded, - onClick, -}: { - isExpanded: boolean - onClick: (e: React.MouseEvent) => void -}) => ( -
+ ) +}) + +/** + * Iteration node component - shows iteration header with nested blocks + */ +const IterationNodeRow = memo(function IterationNodeRow({ + node, + selectedEntryId, + onSelectEntry, + isExpanded, + onToggle, +}: { + node: EntryNode + selectedEntryId: string | null + onSelectEntry: (entry: ConsoleEntry) => void + isExpanded: boolean + onToggle: () => void +}) { + const { entry, children, iterationInfo } = node + const hasError = Boolean(entry.error) || children.some((c) => c.entry.error) + const hasChildren = children.length > 0 + const hasRunningChild = children.some((c) => c.entry.isRunning) + const hasCanceledChild = children.some((c) => c.entry.isCanceled) && !hasRunningChild + + const iterationLabel = iterationInfo + ? `Iteration ${iterationInfo.current}${iterationInfo.total !== undefined ? ` / ${iterationInfo.total}` : ''}` + : entry.blockName + + return ( +
+ {/* Iteration Header */} +
{ + e.stopPropagation() + onToggle() + }} + > +
+ + {iterationLabel} + + {hasChildren && ( + + )} +
+ + + +
+ + {/* Nested Blocks */} + {isExpanded && hasChildren && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ) +}) + +/** + * Subflow node component - shows subflow header with nested iterations + */ +const SubflowNodeRow = memo(function SubflowNodeRow({ + node, + selectedEntryId, + onSelectEntry, + expandedNodes, + onToggleNode, +}: { + node: EntryNode + selectedEntryId: string | null + onSelectEntry: (entry: ConsoleEntry) => void + expandedNodes: Set + onToggleNode: (nodeId: string) => void +}) { + const { entry, children } = node + const BlockIcon = getBlockIcon(entry.blockType) + const hasError = + Boolean(entry.error) || + children.some((c) => c.entry.error || c.children.some((gc) => gc.entry.error)) + const bgColor = getBlockColor(entry.blockType) + const nodeId = entry.id + const isExpanded = expandedNodes.has(nodeId) + const hasChildren = children.length > 0 + + // Check if any nested block is running or canceled + const hasRunningDescendant = children.some( + (c) => c.entry.isRunning || c.children.some((gc) => gc.entry.isRunning) + ) + const hasCanceledDescendant = + children.some((c) => c.entry.isCanceled || c.children.some((gc) => gc.entry.isCanceled)) && + !hasRunningDescendant + + const displayName = + entry.blockType === 'loop' + ? 'Loop' + : entry.blockType === 'parallel' + ? 'Parallel' + : entry.blockName + + return ( +
+ {/* Subflow Header */} +
{ + e.stopPropagation() + onToggleNode(nodeId) + }} + > +
+
+ {BlockIcon && } +
+ + {displayName} + + {hasChildren && ( + + )} +
+ + + +
+ + {/* Nested Iterations */} + {isExpanded && hasChildren && ( +
+ {children.map((iterNode) => ( + onToggleNode(iterNode.entry.id)} + /> + ))} +
+ )} +
+ ) +}) + +/** + * Entry node component - dispatches to appropriate component based on node type + */ +const EntryNodeRow = memo(function EntryNodeRow({ + node, + selectedEntryId, + onSelectEntry, + expandedNodes, + onToggleNode, +}: { + node: EntryNode + selectedEntryId: string | null + onSelectEntry: (entry: ConsoleEntry) => void + expandedNodes: Set + onToggleNode: (nodeId: string) => void +}) { + const { nodeType } = node + + if (nodeType === 'subflow') { + return ( + + ) + } + + if (nodeType === 'iteration') { + return ( + onToggleNode(node.entry.id)} + /> + ) + } + + // Regular block + return ( + - -) + ) +}) /** - * Truncates execution ID for display as run ID + * Status badge component for execution rows */ -const formatRunId = (executionId?: string): string => { - if (!executionId) return '-' - return executionId.slice(0, 8) -} - -/** - * Run ID colors - */ -const RUN_ID_COLORS = [ - '#4ADE80', // Green - '#F472B6', // Pink - '#60C5FF', // Blue - '#FF8533', // Orange - '#C084FC', // Purple - '#EAB308', // Yellow - '#2DD4BF', // Teal - '#FB7185', // Rose -] as const - -/** - * Gets color for a run ID from the precomputed color map. - */ -const getRunIdColor = (executionId: string | undefined, colorMap: Map) => { - if (!executionId) return null - return colorMap.get(executionId) ?? null -} - -/** - * Determines if a keyboard event originated from a text-editable element. - * - * Treats native inputs, textareas, contenteditable elements, and elements with - * textbox-like roles as editable. If the event target or any of its ancestors - * match these criteria, we consider it editable and skip global key handlers. - * - * @param e - Keyboard event to inspect - * @returns True if the event is from an editable context, false otherwise - */ -const 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 +const StatusBadge = memo(function StatusBadge({ + hasError, + isRunning, + isCanceled, +}: { + hasError: boolean + isRunning: boolean + isCanceled: boolean +}) { + if (isRunning) { + return ( + + Running + + ) } - - let el: HTMLElement | null = target - while (el) { - if (isEditable(el)) return true - el = el.parentElement + if (isCanceled) { + return ( + + canceled + + ) } - return false -} + return ( + + {hasError ? 'error' : 'info'} + + ) +}) + +/** + * Execution row component with expand/collapse + */ +const ExecutionRow = memo(function ExecutionRow({ + group, + isExpanded, + onToggle, + selectedEntryId, + onSelectEntry, + expandedNodes, + onToggleNode, +}: { + group: ExecutionGroup + isExpanded: boolean + onToggle: () => void + selectedEntryId: string | null + onSelectEntry: (entry: ConsoleEntry) => void + expandedNodes: Set + 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 ( +
+ {/* Execution header */} +
+
+ + Run #{formatRunId(group.executionId)} + + + +
+ + + +
+ + {/* Expanded content - Tree structure */} + {isExpanded && ( +
+
+ {group.entryTree.map((node) => ( + + ))} +
+
+ )} +
+ ) +}) /** * Terminal component with resizable height that persists across page refreshes. - * - * Uses a CSS-based approach to prevent hydration mismatches: - * 1. Height is controlled by CSS variable (--terminal-height) - * 2. Blocking script in layout.tsx sets CSS variable before React hydrates - * 3. Store updates CSS variable when height changes - * - * This ensures server and client render identical HTML, preventing hydration errors. - * - * @returns Terminal at the bottom of the workflow */ export const Terminal = memo(function Terminal() { const terminalRef = useRef(null) + const logsContainerRef = useRef(null) const prevEntriesLengthRef = useRef(0) const prevWorkflowEntriesLengthRef = useRef(0) const hasInitializedEntriesRef = useRef(false) const isTerminalFocusedRef = useRef(false) const lastExpandedHeightRef = useRef(DEFAULT_EXPANDED_HEIGHT) + + // Store refs for keyboard handler to avoid stale closures + const selectedEntryRef = useRef(null) + const navigableEntriesRef = useRef([]) + const showInputRef = useRef(false) + const hasInputDataRef = useRef(false) + const isExpandedRef = useRef(false) + const setTerminalHeight = useTerminalStore((state) => state.setTerminalHeight) const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth) const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth) @@ -258,28 +549,31 @@ export const Terminal = memo(function Terminal() { const structuredView = useTerminalStore((state) => state.structuredView) const setStructuredView = useTerminalStore((state) => state.setStructuredView) const setHasHydrated = useTerminalStore((state) => state.setHasHydrated) - const isExpanded = useTerminalStore((state) => state.terminalHeight > NEAR_MIN_THRESHOLD) + const isExpanded = useTerminalStore( + (state) => state.terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD + ) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated) - const workflowEntriesSelector = useCallback( - (state: { entries: ConsoleEntry[] }) => - state.entries.filter((entry) => entry.workflowId === activeWorkflowId), - [activeWorkflowId] - ) - const entriesFromStore = useTerminalConsoleStore(useShallow(workflowEntriesSelector)) - const entries = hasConsoleHydrated ? entriesFromStore : [] + + // Get all entries and filter in useMemo to avoid new array on every store update + const allStoreEntries = useTerminalConsoleStore((state) => state.entries) + const entries = useMemo(() => { + if (!hasConsoleHydrated) return [] + return allStoreEntries.filter((entry) => entry.workflowId === activeWorkflowId) + }, [allStoreEntries, activeWorkflowId, hasConsoleHydrated]) + const clearWorkflowConsole = useTerminalConsoleStore((state) => state.clearWorkflowConsole) const exportConsoleCSV = useTerminalConsoleStore((state) => state.exportConsoleCSV) + const [selectedEntry, setSelectedEntry] = useState(null) + const [expandedExecutions, setExpandedExecutions] = useState>(new Set()) + const [expandedNodes, setExpandedNodes] = useState>(new Set()) const [isToggling, setIsToggling] = useState(false) const [showCopySuccess, setShowCopySuccess] = useState(false) const [showInput, setShowInput] = useState(false) const [autoSelectEnabled, setAutoSelectEnabled] = useState(true) - const [blockFilterOpen, setBlockFilterOpen] = useState(false) - const [statusFilterOpen, setStatusFilterOpen] = useState(false) - const [runIdFilterOpen, setRunIdFilterOpen] = useState(false) + const [filtersOpen, setFiltersOpen] = useState(false) const [mainOptionsOpen, setMainOptionsOpen] = useState(false) - const [outputOptionsOpen, setOutputOptionsOpen] = useState(false) const [isTrainingEnvEnabled, setIsTrainingEnvEnabled] = useState(false) const showTrainingControls = useShowTrainingControls() @@ -302,22 +596,15 @@ export const Terminal = memo(function Terminal() { hasActiveFilters, } = useTerminalFilters() - const [contextMenuEntry, setContextMenuEntry] = useState(null) - const { isOpen: isLogRowMenuOpen, position: logRowMenuPosition, menuRef: logRowMenuRef, - handleContextMenu: handleLogRowContextMenu, closeMenu: closeLogRowMenu, } = useContextMenu() /** - * Expands the terminal to its last meaningful height, with safeguards: - * - Never expands below {@link DEFAULT_EXPANDED_HEIGHT}. - * - Never exceeds 70% of the viewport height. - * - * Uses ref for lastExpandedHeight to avoid re-renders during resize. + * Expands the terminal to its last meaningful height */ const expandToLastHeight = useCallback(() => { setIsToggling(true) @@ -339,6 +626,26 @@ export const Terminal = memo(function Terminal() { return filterEntries(allWorkflowEntries) }, [allWorkflowEntries, filterEntries]) + /** + * Group filtered entries by execution + */ + const executionGroups = useMemo(() => { + return groupEntriesByExecution(filteredEntries) + }, [filteredEntries]) + + /** + * Navigable block entries for keyboard navigation. + * Only includes actual block outputs (not subflows/iterations/headers). + * Includes parent node IDs for auto-expanding when navigating. + */ + const navigableEntries = useMemo(() => { + const result: NavigableBlockEntry[] = [] + for (const group of executionGroups) { + result.push(...flattenBlockEntriesOnly(group.entryTree, group.executionId)) + } + return result + }, [executionGroups]) + /** * Get unique blocks (by ID) from all workflow entries */ @@ -370,15 +677,7 @@ export const Terminal = memo(function Terminal() { }, [allWorkflowEntries]) /** - * Check if there are any entries with status information (error or success) - */ - const hasStatusEntries = useMemo(() => { - return allWorkflowEntries.some((entry) => entry.error || entry.success !== undefined) - }, [allWorkflowEntries]) - - /** - * Track color offset - increments when old executions are trimmed - * so remaining executions keep their colors. + * Track color offset for run IDs */ const colorStateRef = useRef<{ executionIds: string[]; offset: number }>({ executionIds: [], @@ -386,9 +685,7 @@ export const Terminal = memo(function Terminal() { }) /** - * Compute colors for each execution ID using sequential assignment. - * Colors cycle through RUN_ID_COLORS based on position + offset. - * When old executions are trimmed, offset increments to preserve colors. + * Compute colors for each execution ID */ const executionColorMap = useMemo(() => { const currentIds: string[] = [] @@ -459,6 +756,15 @@ export const Terminal = memo(function Terminal() { return JSON.stringify(outputData, null, 2) }, [outputData]) + // Keep refs in sync for keyboard handler + useEffect(() => { + selectedEntryRef.current = selectedEntry + navigableEntriesRef.current = navigableEntries + showInputRef.current = showInput + hasInputDataRef.current = hasInputData + isExpandedRef.current = isExpanded + }, [selectedEntry, navigableEntries, showInput, hasInputData, isExpanded]) + /** * Reset entry tracking when switching workflows to ensure auto-open * works correctly for each workflow independently. @@ -509,17 +815,97 @@ export const Terminal = memo(function Terminal() { ]) /** - * Handle row click - toggle if clicking same entry - * Disables auto-selection when user manually selects, re-enables when deselecting - * Also focuses the terminal to enable keyboard navigation + * Auto-expand newest execution, subflows, and iterations when new entries arrive. + * This always runs regardless of autoSelectEnabled - new runs should always be visible. */ - const handleRowClick = useCallback((entry: ConsoleEntry) => { - // Focus the terminal to enable keyboard navigation + useEffect(() => { + if (executionGroups.length === 0) return + + 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) { + if (node.nodeType === 'subflow' && node.children.length > 0) { + nodeIdsToExpand.push(node.entry.id) + // Also expand all iteration children + for (const iterNode of node.children) { + if (iterNode.nodeType === 'iteration') { + nodeIdsToExpand.push(iterNode.entry.id) + } + } + } + } + + if (nodeIdsToExpand.length > 0) { + setExpandedNodes((prev) => { + const hasAll = nodeIdsToExpand.every((id) => prev.has(id)) + if (hasAll) return prev + const next = new Set(prev) + nodeIdsToExpand.forEach((id) => next.add(id)) + return next + }) + } + }, [executionGroups]) + + /** + * Focus the terminal for keyboard navigation + */ + const focusTerminal = useCallback(() => { terminalRef.current?.focus() - setSelectedEntry((prev) => { - const isDeselecting = prev?.id === entry.id - setAutoSelectEnabled(isDeselecting) - return isDeselecting ? null : entry + isTerminalFocusedRef.current = true + }, []) + + /** + * Handle entry selection + */ + const handleSelectEntry = useCallback( + (entry: ConsoleEntry) => { + focusTerminal() + setSelectedEntry((prev) => { + const isDeselecting = prev?.id === entry.id + setAutoSelectEnabled(isDeselecting) + return isDeselecting ? 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 + */ + const handleToggleNode = useCallback((nodeId: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev) + if (next.has(nodeId)) { + next.delete(nodeId) + } else { + next.add(nodeId) + } + return next }) }, []) @@ -536,44 +922,29 @@ export const Terminal = memo(function Terminal() { setIsToggling(false) }, []) - /** - * Handle terminal focus - enables keyboard navigation - */ const handleTerminalFocus = useCallback(() => { isTerminalFocusedRef.current = true }, []) - /** - * Handle terminal blur - disables keyboard navigation - */ const handleTerminalBlur = useCallback((e: React.FocusEvent) => { - // Only blur if focus is moving outside the terminal if (!terminalRef.current?.contains(e.relatedTarget as Node)) { isTerminalFocusedRef.current = false } }, []) - /** - * Handle copy output to clipboard - */ const handleCopy = useCallback(() => { if (!selectedEntry) return - const textToCopy = shouldShowCodeDisplay ? selectedEntry.input.code : outputDataStringified - navigator.clipboard.writeText(textToCopy) setShowCopySuccess(true) }, [selectedEntry, outputDataStringified, shouldShowCodeDisplay]) - /** - * Clears the console for the active workflow. - * - * Extracted so it can be reused both by click handlers and global commands. - */ const clearCurrentWorkflowConsole = useCallback(() => { if (activeWorkflowId) { clearWorkflowConsole(activeWorkflowId) setSelectedEntry(null) + setExpandedExecutions(new Set()) + setExpandedNodes(new Set()) } }, [activeWorkflowId, clearWorkflowConsole]) @@ -595,14 +966,6 @@ export const Terminal = memo(function Terminal() { [activeWorkflowId, exportConsoleCSV] ) - const handleRowContextMenu = useCallback( - (e: React.MouseEvent, entry: ConsoleEntry) => { - setContextMenuEntry(entry) - handleLogRowContextMenu(e) - }, - [handleLogRowContextMenu] - ) - const handleFilterByBlock = useCallback( (blockId: string) => { toggleBlock(blockId) @@ -664,13 +1027,6 @@ export const Terminal = memo(function Terminal() { const shouldShowTrainingButton = isTrainingEnvEnabled && showTrainingControls - /** - * Register global keyboard shortcuts for the terminal: - * - Mod+D: Clear terminal console for the active workflow - * - * The command is disabled in editable contexts so it does not interfere - * with typing inside inputs, textareas, or editors. - */ useRegisterGlobalCommands(() => createCommands([ { @@ -685,62 +1041,40 @@ export const Terminal = memo(function Terminal() { ]) ) - /** - * Mark hydration as complete on mount - */ useEffect(() => { setHasHydrated(true) }, [setHasHydrated]) - /** - * Sync lastExpandedHeightRef with store value on mount. - * Uses subscription to keep ref updated without causing re-renders. - */ useEffect(() => { - // Initialize with current value lastExpandedHeightRef.current = useTerminalStore.getState().lastExpandedHeight - const unsub = useTerminalStore.subscribe((state) => { lastExpandedHeightRef.current = state.lastExpandedHeight }) return unsub }, []) - /** - * Check environment variables on mount - */ useEffect(() => { setIsTrainingEnvEnabled(isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))) setIsPlaygroundEnabled(isTruthy(getEnv('NEXT_PUBLIC_ENABLE_PLAYGROUND'))) }, []) - /** - * Adjust showInput when selected entry changes - * Stay on input view if the new entry has input data - */ useEffect(() => { if (!selectedEntry) { setShowInput(false) return } - - // If we're viewing input but the new entry has no input, switch to output if (showInput) { const newHasInput = selectedEntry.input && (typeof selectedEntry.input === 'object' ? Object.keys(selectedEntry.input).length > 0 : true) - if (!newHasInput) { setShowInput(false) } } }, [selectedEntry, showInput]) - /** - * Reset copy success state after 2 seconds - */ useEffect(() => { if (showCopySuccess) { const timer = setTimeout(() => { @@ -751,96 +1085,224 @@ export const Terminal = memo(function Terminal() { }, [showCopySuccess]) /** - * Auto-select the latest entry when new logs arrive - * Re-enables auto-selection when all entries are cleared - * Only auto-selects when NEW entries are added (length increases) + * 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' }) + } + }) + }, []) + + /** + * Auto-select the last entry (bottom of the list) when new logs arrive. */ useEffect(() => { - if (filteredEntries.length === 0) { - // Re-enable auto-selection when console is cleared + if (executionGroups.length === 0 || navigableEntries.length === 0) { setAutoSelectEnabled(true) setSelectedEntry(null) prevEntriesLengthRef.current = 0 return } - // Auto-select the latest entry only when a NEW entry is added (length increased) - if (autoSelectEnabled && filteredEntries.length > prevEntriesLengthRef.current) { - const latestEntry = filteredEntries[0] - setSelectedEntry(latestEntry) - } + 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 - prevEntriesLengthRef.current = filteredEntries.length - }, [filteredEntries, autoSelectEnabled]) + for (const navEntry of navigableEntries) { + if (navEntry.executionId === newestExecutionId) { + lastNavEntry = navEntry + } else { + break + } + } - /** - * Handle keyboard navigation through logs - * Disables auto-selection when user manually navigates - * Only active when the terminal is focused - */ - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle navigation when terminal is focused - if (!isTerminalFocusedRef.current) return - if (isEventFromEditableElement(e)) return - const activeElement = document.activeElement as HTMLElement | null - const toolbarRoot = document.querySelector( - '[data-toolbar-root][data-search-active=\"true\"]' - ) as HTMLElement | null - if (toolbarRoot && activeElement && toolbarRoot.contains(activeElement)) { + if (!lastNavEntry) { + prevEntriesLengthRef.current = navigableEntries.length return } - if (!selectedEntry || filteredEntries.length === 0) return + setSelectedEntry(lastNavEntry.entry) + focusTerminal() - if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return - - e.preventDefault() - - const currentIndex = filteredEntries.findIndex((entry) => entry.id === selectedEntry.id) - if (currentIndex === -1) return - - if (e.key === 'ArrowUp' && currentIndex > 0) { - setAutoSelectEnabled(false) - setSelectedEntry(filteredEntries[currentIndex - 1]) - } else if (e.key === 'ArrowDown' && currentIndex < filteredEntries.length - 1) { - setAutoSelectEnabled(false) - setSelectedEntry(filteredEntries[currentIndex + 1]) + // 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() } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEntry, filteredEntries]) + prevEntriesLengthRef.current = navigableEntries.length + }, [executionGroups, navigableEntries, autoSelectEnabled, focusTerminal, scrollToBottom]) /** - * Handle keyboard navigation for input/output toggle - * Left arrow shows output, right arrow shows input - * Only active when the terminal is focused + * Sync selected entry with latest data from store. + * This ensures the output panel updates when a running block completes or is canceled. + */ + useEffect(() => { + if (!selectedEntry) return + + const updatedEntry = filteredEntries.find((e) => e.id === selectedEntry.id) + if (updatedEntry && updatedEntry !== selectedEntry) { + // Only update if the entry data has actually changed + const hasChanged = + updatedEntry.output !== selectedEntry.output || + updatedEntry.isRunning !== selectedEntry.isRunning || + updatedEntry.isCanceled !== selectedEntry.isCanceled || + updatedEntry.durationMs !== selectedEntry.durationMs || + updatedEntry.error !== selectedEntry.error || + updatedEntry.success !== selectedEntry.success + if (hasChanged) { + setSelectedEntry(updatedEntry) + } + } + }, [filteredEntries, selectedEntry]) + + /** + * Clear filters when there are no logs + */ + useEffect(() => { + if (allWorkflowEntries.length === 0 && hasActiveFilters) { + clearFilters() + } + }, [allWorkflowEntries.length, hasActiveFilters, clearFilters]) + + /** + * Navigate to a block entry and auto-expand its parents + */ + const navigateToEntry = useCallback( + (navEntry: NavigableBlockEntry) => { + 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) => { + const hasAll = navEntry.parentNodeIds.every((id) => prev.has(id)) + if (hasAll) return prev + const next = new Set(prev) + navEntry.parentNodeIds.forEach((id) => next.add(id)) + return next + }) + } + + // Keep terminal focused for continued navigation + focusTerminal() + + // Scroll entry into view if needed + scrollEntryIntoView(navEntry.entry.id) + }, + [focusTerminal, scrollEntryIntoView] + ) + + /** + * Consolidated keyboard handler for all terminal navigation */ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Only handle navigation when terminal is focused - if (!isTerminalFocusedRef.current) return - // Ignore when typing/navigating inside editable inputs/editors + // Common guards if (isEventFromEditableElement(e)) return - if (!selectedEntry) return - - if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return - - e.preventDefault() - - if (!isExpanded) { - expandToLastHeight() + const activeElement = document.activeElement as HTMLElement | null + const searchOverlay = document.querySelector('[data-toolbar-root][data-search-active="true"]') + if (searchOverlay && activeElement && searchOverlay.contains(activeElement)) { + return } - if (e.key === 'ArrowLeft') { - if (showInput) { - setShowInput(false) + const currentEntry = selectedEntryRef.current + const entries = navigableEntriesRef.current + + // Escape to unselect + if (e.key === 'Escape') { + if (currentEntry) { + e.preventDefault() + setSelectedEntry(null) + setAutoSelectEnabled(true) } - } else if (e.key === 'ArrowRight') { - if (!showInput && hasInputData) { + return + } + + // Terminal must be focused for arrow keys + if (!isTerminalFocusedRef.current) return + + // Arrow up/down for entry navigation (only block outputs) + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + if (entries.length === 0) return + + e.preventDefault() + + // If no entry selected, select the first or last based on direction + if (!currentEntry) { + const targetEntry = e.key === 'ArrowDown' ? entries[0] : entries[entries.length - 1] + navigateToEntry(targetEntry) + return + } + + const currentIndex = entries.findIndex((navEntry) => navEntry.entry.id === currentEntry.id) + if (currentIndex === -1) { + // Current entry not in navigable list (shouldn't happen), select first + navigateToEntry(entries[0]) + return + } + + if (e.key === 'ArrowUp' && currentIndex > 0) { + navigateToEntry(entries[currentIndex - 1]) + } else if (e.key === 'ArrowDown' && currentIndex < entries.length - 1) { + navigateToEntry(entries[currentIndex + 1]) + } + return + } + + // Arrow left/right for input/output toggle + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + if (!currentEntry) return + + e.preventDefault() + + if (!isExpandedRef.current) { + expandToLastHeight() + } + + if (e.key === 'ArrowLeft' && showInputRef.current) { + setShowInput(false) + } else if (e.key === 'ArrowRight' && !showInputRef.current && hasInputDataRef.current) { setShowInput(true) } } @@ -848,35 +1310,10 @@ export const Terminal = memo(function Terminal() { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded]) + }, [expandToLastHeight, navigateToEntry]) /** - * Handle Escape to unselect entry (search close is handled by OutputPanel internally) - * Check if the focused element is in the search overlay to avoid conflicting with search close. - */ - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Escape' || !selectedEntry) return - - // Don't unselect if focus is in the search overlay (search close takes priority) - const activeElement = document.activeElement as HTMLElement | null - const searchOverlay = document.querySelector('[data-toolbar-root][data-search-active="true"]') - if (searchOverlay && activeElement && searchOverlay.contains(activeElement)) { - return - } - - e.preventDefault() - setSelectedEntry(null) - setAutoSelectEnabled(true) - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEntry]) - - /** - * Adjust output panel width when sidebar or panel width changes. - * Ensures output panel doesn't exceed maximum allowed width. + * Adjust output panel width on resize */ useEffect(() => { const handleResize = () => { @@ -890,7 +1327,7 @@ export const Terminal = memo(function Terminal() { ) const terminalWidth = window.innerWidth - sidebarWidth - panelWidth - const maxWidth = terminalWidth - BLOCK_COLUMN_WIDTH_PX + const maxWidth = terminalWidth - TERMINAL_CONFIG.BLOCK_COLUMN_WIDTH_PX if (outputPanelWidth > maxWidth && maxWidth >= MIN_OUTPUT_PANEL_WIDTH_PX) { setOutputPanelWidth(Math.max(maxWidth, MIN_OUTPUT_PANEL_WIDTH_PX)) @@ -940,215 +1377,64 @@ export const Terminal = memo(function Terminal() { aria-label='Terminal' >
- {/* Left Section - Logs Table */} + {/* Left Section - Logs */}
{/* Header */}
- {uniqueBlocks.length > 0 ? ( -
- - - - - e.stopPropagation()} - minWidth={120} - maxWidth={200} - > - - {uniqueBlocks.map((block, index) => { - const BlockIcon = getBlockIcon(block.blockType) - const isSelected = filters.blockIds.has(block.blockId) + {/* Left side - Logs label */} + Logs - return ( - toggleBlock(block.blockId)} - className={index > 0 ? 'mt-[2px]' : ''} - > - {BlockIcon && } - {block.blockName} - - ) - })} - - - -
- ) : ( - - )} - {hasStatusEntries ? ( -
- - - - - e.stopPropagation()} - style={{ minWidth: '120px', maxWidth: '120px' }} - > - - toggleStatus('error')} - > -
- Error - - toggleStatus('info')} - className='mt-[2px]' - > -
- Info - - - - -
- ) : ( - - )} - {uniqueRunIds.length > 0 ? ( -
- - - - - e.stopPropagation()} - style={{ minWidth: '90px', maxWidth: '90px' }} - > - - {uniqueRunIds.map((runId, index) => { - const isSelected = filters.runIds.has(runId) - const runIdColor = getRunIdColor(runId, executionColorMap) - - return ( - toggleRunId(runId)} - className={index > 0 ? 'mt-[2px]' : ''} - > - - {formatRunId(runId)} - - - ) - })} - - - -
- ) : ( - - )} - - {allWorkflowEntries.length > 0 ? ( -
- -
- ) : ( - - )} + {/* Right side - Filters and icons */} {!selectedEntry && ( -
+
+ {/* Unified filter popover */} + {allWorkflowEntries.length > 0 && ( + + )} + + {/* Sort toggle */} + {allWorkflowEntries.length > 0 && ( + + + + + + Sort by time + + + )} + {isPlaygroundEnabled && ( @@ -1167,6 +1453,7 @@ export const Terminal = memo(function Terminal() { )} + {shouldShowTrainingButton && ( @@ -1191,6 +1478,7 @@ export const Terminal = memo(function Terminal() { )} + {hasActiveFilters && ( @@ -1211,6 +1499,7 @@ export const Terminal = memo(function Terminal() { )} + {filteredEntries.length > 0 && ( <> @@ -1245,6 +1534,7 @@ export const Terminal = memo(function Terminal() { )} +
- {/* Rows */} -
- {filteredEntries.length === 0 ? ( + {/* Execution list */} +
+ {executionGroups.length === 0 ? (
No logs yet
) : ( - filteredEntries.map((entry) => { - const statusInfo = getStatusInfo(entry.success, entry.error) - const isSelected = selectedEntry?.id === entry.id - const BlockIcon = getBlockIcon(entry.blockType) - const runIdColor = getRunIdColor(entry.executionId, executionColorMap) - - return ( -
handleRowClick(entry)} - onContextMenu={(e) => handleRowContextMenu(e, entry)} - > - {/* Block */} -
- {BlockIcon && ( - - )} - {entry.blockName} -
- - {/* Status */} -
- {statusInfo ? ( - - {statusInfo.label} - - ) : ( - - - )} -
- - {/* Run ID */} - - {formatRunId(entry.executionId)} - - - {/* Duration */} - - {formatDuration(entry.durationMs)} - - - {/* Timestamp */} - - {formatTimeWithSeconds(new Date(entry.timestamp))} - -
- ) - }) + executionGroups.map((group) => ( + handleToggleExecution(group.executionId)} + selectedEntryId={selectedEntry?.id || null} + onSelectEntry={handleSelectEntry} + expandedNodes={expandedNodes} + onToggleNode={handleToggleNode} + /> + )) )}
@@ -1390,7 +1608,6 @@ export const Terminal = memo(function Terminal() { {selectedEntry && ( )}
@@ -1432,7 +1648,7 @@ export const Terminal = memo(function Terminal() { position={logRowMenuPosition} menuRef={logRowMenuRef} onClose={closeLogRowMenu} - entry={contextMenuEntry} + entry={selectedEntry} filters={filters} onFilterByBlock={handleFilterByBlock} onFilterByStatus={handleFilterByStatus} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx new file mode 100644 index 000000000..35c8607a4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx @@ -0,0 +1,111 @@ +'use client' + +import { memo } from 'react' +import { Badge } from '@/components/emcn' + +/** + * Terminal filter configuration state + */ +export interface TerminalFilters { + blockIds: Set + statuses: Set<'error' | 'info'> + runIds: Set +} + +/** + * Context menu position for positioning floating menus + */ +export interface ContextMenuPosition { + x: number + y: number +} + +/** + * Sort field options for terminal entries + */ +export type SortField = 'timestamp' + +/** + * Sort direction options + */ +export type SortDirection = 'asc' | 'desc' + +/** + * Sort configuration for terminal entries + */ +export interface SortConfig { + field: SortField + direction: SortDirection +} + +/** + * Status type for console entries + */ +export type EntryStatus = 'error' | 'info' + +/** + * Block information for filters + */ +export interface BlockInfo { + blockId: string + blockName: string + blockType: string +} + +/** + * Common row styling classes for terminal components + */ +export const ROW_STYLES = { + base: 'group flex cursor-pointer items-center justify-between gap-[8px] rounded-[8px] px-[6px]', + selected: 'bg-[var(--surface-6)] dark:bg-[var(--surface-5)]', + hover: 'hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]', + nested: + 'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]', + iconButton: '!p-1.5 -m-1.5', +} as const + +/** + * Common badge styling for status badges + */ +export const BADGE_STYLES = { + base: 'rounded-[4px] px-[4px] py-[0px] text-[11px]', + mono: 'rounded-[4px] px-[4px] py-[0px] font-mono text-[11px]', +} as const + +/** + * Running badge component - displays a consistent "Running" indicator + */ +export const RunningBadge = memo(function RunningBadge() { + return ( + + Running + + ) +}) + +/** + * 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 + } + if (isCanceled) { + return <>canceled + } + return <>{formattedDuration} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts new file mode 100644 index 000000000..c0a9dfca2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts @@ -0,0 +1,488 @@ +'use client' + +import type React from 'react' +import { RepeatIcon, SplitIcon } from 'lucide-react' +import { getBlock } from '@/blocks' +import type { ConsoleEntry } from '@/stores/terminal' + +/** + * Subflow colors matching the subflow tool configs + */ +const SUBFLOW_COLORS = { + loop: '#2FB3FF', + parallel: '#FEE12B', +} as const + +/** + * Run ID color palette for visual distinction between executions + */ +export const RUN_ID_COLORS = [ + '#4ADE80', // Green + '#F472B6', // Pink + '#60C5FF', // Blue + '#FF8533', // Orange + '#C084FC', // Purple + '#EAB308', // Yellow + '#2DD4BF', // Teal + '#FB7185', // Rose +] as const + +/** + * Retrieves the icon component for a given block type + */ +export function getBlockIcon( + blockType: string +): React.ComponentType<{ className?: string }> | null { + const blockConfig = getBlock(blockType) + + if (blockConfig?.icon) { + return blockConfig.icon + } + + if (blockType === 'loop') { + return RepeatIcon + } + + if (blockType === 'parallel') { + return SplitIcon + } + + return null +} + +/** + * Gets the background color for a block type + */ +export function getBlockColor(blockType: string): string { + const blockConfig = getBlock(blockType) + if (blockConfig?.bgColor) { + return blockConfig.bgColor + } + // Use proper subflow colors matching the toolbar configs + if (blockType === 'loop') { + return SUBFLOW_COLORS.loop + } + if (blockType === 'parallel') { + return SUBFLOW_COLORS.parallel + } + return '#6b7280' +} + +/** + * Formats duration from milliseconds to readable format + */ +export function formatDuration(ms?: number): string { + if (ms === undefined || ms === null) return '-' + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +/** + * Truncates execution ID for display as run ID + */ +export function formatRunId(executionId?: string): string { + if (!executionId) return '-' + return executionId.slice(0, 8) +} + +/** + * Gets color for a run ID from the precomputed color map + */ +export function getRunIdColor( + executionId: string | undefined, + colorMap: Map +): string | null { + if (!executionId) return null + return colorMap.get(executionId) ?? null +} + +/** + * Determines if a keyboard event originated from a text-editable element + */ +export function isEventFromEditableElement(e: KeyboardEvent): boolean { + const target = e.target as HTMLElement | null + if (!target) return false + + const isEditable = (el: HTMLElement | null): boolean => { + if (!el) return false + if (el instanceof HTMLInputElement) return true + if (el instanceof HTMLTextAreaElement) return true + if ((el as HTMLElement).isContentEditable) return true + const role = el.getAttribute('role') + if (role === 'textbox' || role === 'combobox') return true + return false + } + + let el: HTMLElement | null = target + while (el) { + if (isEditable(el)) return true + el = el.parentElement + } + return false +} + +/** + * Checks if a block type is a subflow (loop or parallel) + */ +export function isSubflowBlockType(blockType: string): boolean { + const lower = blockType?.toLowerCase() || '' + return lower === 'loop' || lower === 'parallel' +} + +/** + * Node type for the tree structure + */ +export type EntryNodeType = 'block' | 'subflow' | 'iteration' + +/** + * Entry node for tree structure - represents a block, subflow, or iteration + */ +export interface EntryNode { + /** The console entry (for blocks) or synthetic entry (for subflows/iterations) */ + entry: ConsoleEntry + /** Child nodes */ + children: EntryNode[] + /** Node type */ + nodeType: EntryNodeType + /** Iteration info for iteration nodes */ + iterationInfo?: { + current: number + total?: number + } +} + +/** + * Execution group interface for grouping entries by execution + */ +export interface ExecutionGroup { + executionId: string + startTime: string + endTime: string + startTimeMs: number + endTimeMs: number + duration: number + status: 'success' | 'error' + /** Flat list of entries (legacy, kept for filters) */ + entries: ConsoleEntry[] + /** Tree structure of entry nodes for nested display */ + entryTree: EntryNode[] +} + +/** + * Iteration group for grouping blocks within the same iteration + */ +interface IterationGroup { + iterationType: string + iterationCurrent: number + iterationTotal?: number + blocks: ConsoleEntry[] + startTimeMs: number +} + +/** + * Builds a tree structure from flat entries. + * Groups iteration entries by (iterationType, iterationCurrent), showing all blocks + * that executed within each iteration. + * Sorts by start time to ensure chronological order. + */ +function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { + // Separate regular blocks from iteration entries + const regularBlocks: ConsoleEntry[] = [] + const iterationEntries: ConsoleEntry[] = [] + + for (const entry of entries) { + if (entry.iterationType && entry.iterationCurrent !== undefined) { + iterationEntries.push(entry) + } else { + regularBlocks.push(entry) + } + } + + // Group iteration entries by (iterationType, iterationCurrent) + const iterationGroupsMap = new Map() + 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() + 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; entries: ConsoleEntry[] } + >() + + for (const entry of entries) { + const execId = entry.executionId || entry.id + + const entryStartTime = entry.startedAt || entry.timestamp + const entryEndTime = entry.endedAt || entry.timestamp + const entryStartMs = new Date(entryStartTime).getTime() + const entryEndMs = new Date(entryEndTime).getTime() + + let group = groups.get(execId) + + if (!group) { + group = { + meta: { + executionId: execId, + startTime: entryStartTime, + endTime: entryEndTime, + startTimeMs: entryStartMs, + endTimeMs: entryEndMs, + duration: 0, + status: 'success', + entries: [], + }, + entries: [], + } + groups.set(execId, group) + } else { + // Update timing bounds + if (entryStartMs < group.meta.startTimeMs) { + group.meta.startTime = entryStartTime + group.meta.startTimeMs = entryStartMs + } + if (entryEndMs > group.meta.endTimeMs) { + group.meta.endTime = entryEndTime + group.meta.endTimeMs = entryEndMs + } + } + + // Check for errors + if (entry.error) { + group.meta.status = 'error' + } + + group.entries.push(entry) + } + + // Build tree structure for each group + const result: ExecutionGroup[] = [] + for (const group of groups.values()) { + group.meta.duration = group.meta.endTimeMs - group.meta.startTimeMs + group.meta.entries = group.entries + result.push({ + ...group.meta, + entryTree: buildEntryTree(group.entries), + }) + } + + // Sort by start time descending (newest first) + result.sort((a, b) => b.startTimeMs - a.startTimeMs) + + return result +} + +/** + * Flattens entry tree into display order for keyboard navigation + */ +export function flattenEntryTree(nodes: EntryNode[]): ConsoleEntry[] { + const result: ConsoleEntry[] = [] + for (const node of nodes) { + result.push(node.entry) + if (node.children.length > 0) { + result.push(...flattenEntryTree(node.children)) + } + } + return result +} + +/** + * Block entry with parent tracking for navigation + */ +export interface NavigableBlockEntry { + entry: ConsoleEntry + executionId: string + /** IDs of parent nodes (subflows, iterations) that contain this block */ + parentNodeIds: string[] +} + +/** + * Flattens entry tree to only include actual block entries (not subflows/iterations). + * Also tracks parent node IDs for auto-expanding when navigating. + */ +export function flattenBlockEntriesOnly( + nodes: EntryNode[], + executionId: string, + parentIds: string[] = [] +): NavigableBlockEntry[] { + const result: NavigableBlockEntry[] = [] + for (const node of nodes) { + if (node.nodeType === 'block') { + result.push({ + entry: node.entry, + executionId, + parentNodeIds: parentIds, + }) + } + if (node.children.length > 0) { + const newParentIds = node.nodeType !== 'block' ? [...parentIds, node.entry.id] : parentIds + result.push(...flattenBlockEntriesOnly(node.children, executionId, newParentIds)) + } + } + return result +} + +// BlockInfo is now in types.ts for shared use across terminal components + +/** + * Terminal height configuration constants + */ +export const TERMINAL_CONFIG = { + NEAR_MIN_THRESHOLD: 40, + BLOCK_COLUMN_WIDTH_PX: 240, + HEADER_TEXT_CLASS: 'font-medium text-[var(--text-tertiary)] text-[12px]', +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 1c0cbcc7a..dea55acdc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -81,7 +81,8 @@ export function useWorkflowExecution() { const queryClient = useQueryClient() const currentWorkflow = useCurrentWorkflow() const { activeWorkflowId, workflows } = useWorkflowRegistry() - const { toggleConsole, addConsole } = useTerminalConsoleStore() + const { toggleConsole, addConsole, updateConsole, cancelRunningEntries } = + useTerminalConsoleStore() const { getAllVariables } = useEnvironmentStore() const { getVariablesByWorkflowId, variables } = useVariablesStore() const { @@ -867,6 +868,8 @@ export function useWorkflowExecution() { if (activeWorkflowId) { logger.info('Using server-side executor') + const executionId = uuidv4() + let executionResult: ExecutionResult = { success: false, output: {}, @@ -910,6 +913,27 @@ export function useWorkflowExecution() { incomingEdges.forEach((edge) => { setEdgeRunStatus(edge.id, 'success') }) + + // Add entry to terminal immediately with isRunning=true + const startedAt = new Date().toISOString() + addConsole({ + input: {}, + output: undefined, + success: undefined, + durationMs: undefined, + startedAt, + endedAt: undefined, + workflowId: activeWorkflowId, + blockId: data.blockId, + executionId, + blockName: data.blockName || 'Unknown Block', + blockType: data.blockType || 'unknown', + isRunning: true, + // Pass through iteration context for subflow grouping + iterationCurrent: data.iterationCurrent, + iterationTotal: data.iterationTotal, + iterationType: data.iterationType, + }) }, onBlockCompleted: (data) => { @@ -940,24 +964,23 @@ export function useWorkflowExecution() { endedAt, }) - // Add to console - addConsole({ - input: data.input || {}, - output: data.output, - success: true, - durationMs: data.durationMs, - startedAt, - endedAt, - workflowId: activeWorkflowId, - blockId: data.blockId, - executionId: executionId || uuidv4(), - blockName: data.blockName || 'Unknown Block', - blockType: data.blockType || 'unknown', - // Pass through iteration context for console pills - iterationCurrent: data.iterationCurrent, - iterationTotal: data.iterationTotal, - iterationType: data.iterationType, - }) + // Update existing console entry (created in onBlockStarted) with completion data + updateConsole( + data.blockId, + { + input: data.input || {}, + replaceOutput: data.output, + success: true, + durationMs: data.durationMs, + endedAt, + isRunning: false, + // Pass through iteration context for subflow grouping + iterationCurrent: data.iterationCurrent, + iterationTotal: data.iterationTotal, + iterationType: data.iterationType, + }, + executionId + ) // Call onBlockComplete callback if provided if (onBlockComplete) { @@ -992,25 +1015,24 @@ export function useWorkflowExecution() { endedAt, }) - // Add error to console - addConsole({ - input: data.input || {}, - output: {}, - success: false, - error: data.error, - durationMs: data.durationMs, - startedAt, - endedAt, - workflowId: activeWorkflowId, - blockId: data.blockId, - executionId: executionId || uuidv4(), - blockName: data.blockName, - blockType: data.blockType, - // Pass through iteration context for console pills - iterationCurrent: data.iterationCurrent, - iterationTotal: data.iterationTotal, - iterationType: data.iterationType, - }) + // Update existing console entry (created in onBlockStarted) with error data + updateConsole( + data.blockId, + { + input: data.input || {}, + replaceOutput: {}, + success: false, + error: data.error, + durationMs: data.durationMs, + endedAt, + isRunning: false, + // Pass through iteration context for subflow grouping + iterationCurrent: data.iterationCurrent, + iterationTotal: data.iterationTotal, + iterationType: data.iterationType, + }, + executionId + ) }, onStreamChunk: (data) => { @@ -1089,7 +1111,7 @@ export function useWorkflowExecution() { endedAt: new Date().toISOString(), workflowId: activeWorkflowId, blockId: 'validation', - executionId: executionId || uuidv4(), + executionId, blockName: 'Workflow Validation', blockType: 'validation', }) @@ -1358,6 +1380,11 @@ export function useWorkflowExecution() { // Mark current chat execution as superseded so its cleanup won't affect new executions currentChatExecutionIdRef.current = null + // Mark all running entries as canceled in the terminal + if (activeWorkflowId) { + cancelRunningEntries(activeWorkflowId) + } + // Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx setIsExecuting(false) setIsDebugging(false) @@ -1374,6 +1401,8 @@ export function useWorkflowExecution() { setIsExecuting, setIsDebugging, setActiveBlocks, + activeWorkflowId, + cancelRunningEntries, ]) return { diff --git a/apps/sim/components/emcn/components/code/code.tsx b/apps/sim/components/emcn/components/code/code.tsx index 45ac92cae..58250adc1 100644 --- a/apps/sim/components/emcn/components/code/code.tsx +++ b/apps/sim/components/emcn/components/code/code.tsx @@ -949,6 +949,9 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({ [contentRef] ) + const hasCollapsibleContent = collapsibleLines.size > 0 + const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent + const rowProps = useMemo( () => ({ lines: visibleLines, @@ -957,7 +960,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({ gutterStyle, leftOffset: paddingLeft, wrapText, - showCollapseColumn, + showCollapseColumn: effectiveShowCollapseColumn, collapsibleLines, collapsedLines, onToggleCollapse: toggleCollapse, @@ -969,7 +972,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({ gutterStyle, paddingLeft, wrapText, - showCollapseColumn, + effectiveShowCollapseColumn, collapsibleLines, collapsedLines, toggleCollapse, @@ -1103,7 +1106,10 @@ function ViewerInner({ }, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex]) const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre' - const collapseColumnWidth = showCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0 + + const hasCollapsibleContent = collapsibleLines.size > 0 + const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent + const collapseColumnWidth = effectiveShowCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0 // Grid-based rendering for gutter alignment (works with wrap) if (showGutter) { @@ -1116,7 +1122,7 @@ function ViewerInner({ paddingTop: '8px', paddingBottom: '8px', display: 'grid', - gridTemplateColumns: showCollapseColumn + gridTemplateColumns: effectiveShowCollapseColumn ? `${gutterWidth}px ${collapseColumnWidth}px 1fr` : `${gutterWidth}px 1fr`, }} @@ -1134,7 +1140,7 @@ function ViewerInner({ > {lineNumber}
- {showCollapseColumn && ( + {effectiveShowCollapseColumn && (
{isCollapsible && ( ()( : update.input } + if (update.isRunning !== undefined) { + updatedEntry.isRunning = update.isRunning + } + + if (update.isCanceled !== undefined) { + updatedEntry.isCanceled = update.isCanceled + } + + if (update.iterationCurrent !== undefined) { + updatedEntry.iterationCurrent = update.iterationCurrent + } + + if (update.iterationTotal !== undefined) { + updatedEntry.iterationTotal = update.iterationTotal + } + + if (update.iterationType !== undefined) { + updatedEntry.iterationType = update.iterationType + } + return updatedEntry }) return { entries: updatedEntries } }) }, + + cancelRunningEntries: (workflowId: string) => { + set((state) => { + const updatedEntries = state.entries.map((entry) => { + if (entry.workflowId === workflowId && entry.isRunning) { + return { + ...entry, + isRunning: false, + isCanceled: true, + endedAt: new Date().toISOString(), + } + } + return entry + }) + return { entries: updatedEntries } + }) + }, }), { name: 'terminal-console-store', diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index f496c7356..ca31112eb 100644 --- a/apps/sim/stores/terminal/console/types.ts +++ b/apps/sim/stores/terminal/console/types.ts @@ -20,6 +20,10 @@ export interface ConsoleEntry { iterationCurrent?: number iterationTotal?: number iterationType?: SubflowType + /** Whether this block is currently running */ + isRunning?: boolean + /** Whether this block execution was canceled */ + isCanceled?: boolean } export interface ConsoleUpdate { @@ -32,6 +36,14 @@ export interface ConsoleUpdate { endedAt?: string durationMs?: number input?: any + /** Whether this block is currently running */ + isRunning?: boolean + /** Whether this block execution was canceled */ + isCanceled?: boolean + /** Iteration context for subflow blocks */ + iterationCurrent?: number + iterationTotal?: number + iterationType?: SubflowType } export interface ConsoleStore { @@ -43,6 +55,7 @@ export interface ConsoleStore { getWorkflowEntries: (workflowId: string) => ConsoleEntry[] toggleConsole: () => void updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void + cancelRunningEntries: (workflowId: string) => void _hasHydrated: boolean setHasHydrated: (hasHydrated: boolean) => void }