From 5c02d46d55c537eae7f27691101aba4741c752ac Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:40:43 -0800 Subject: [PATCH] feat(terminal): structured output (#3026) * feat(code): collapsed JSON in terminal * improvement(code): addressed comments * feat(terminal): added structured output; improvement(preview): note block * feat(terminal): log view * improvement(terminal): ui/ux * improvement(terminal): default sizing and collapsed width * fix: code colors, terminal large output handling * fix(terminal): structured search * improvement: preivew accuracy, invite-modal admin, logs live --- apps/sim/app/_styles/globals.css | 2 +- .../components/trace-spans/trace-spans.tsx | 16 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 2 +- .../connection-blocks/connection-blocks.tsx | 8 +- .../filter-popover/filter-popover.tsx | 121 + .../components/filter-popover/index.ts | 1 + .../components/terminal/components/index.ts | 7 +- .../components/log-row-context-menu/index.ts | 1 + .../log-row-context-menu.tsx | 55 +- .../components}/output-context-menu.tsx | 21 +- .../components/structured-output.tsx | 913 ++++++++ .../terminal/components/output-panel/index.ts | 4 + .../components/output-panel/output-panel.tsx | 643 ++++++ .../components/status-display/index.ts | 1 + .../status-display/status-display.tsx | 43 + .../components/toggle-button/index.ts | 1 + .../toggle-button/toggle-button.tsx | 33 + .../components/terminal/hooks/index.ts | 1 + .../terminal/hooks/use-output-panel-resize.ts | 6 +- .../terminal/hooks/use-terminal-filters.ts | 52 +- .../components/terminal/terminal.tsx | 2041 ++++++----------- .../[workflowId]/components/terminal/types.ts | 64 + .../[workflowId]/components/terminal/utils.ts | 452 ++++ .../hooks/use-workflow-execution.ts | 107 +- .../preview-editor/preview-editor.tsx | 20 +- .../components/block/block.tsx | 7 +- .../preview-workflow/preview-workflow.tsx | 137 +- .../components/invite-modal/invite-modal.tsx | 2 +- .../components/emcn/components/code/code.css | 6 +- .../components/emcn/components/code/code.tsx | 856 +++++-- apps/sim/stores/constants.ts | 9 +- apps/sim/stores/terminal/console/store.ts | 37 + apps/sim/stores/terminal/console/types.ts | 13 + apps/sim/stores/terminal/store.ts | 9 + apps/sim/stores/terminal/types.ts | 2 + 35 files changed, 3897 insertions(+), 1796 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 (72%) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/{ => output-panel/components}/output-context-menu.tsx (82%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx 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.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index a17787657..96e8981e3 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -14,7 +14,7 @@ --panel-width: 320px; /* PANEL_WIDTH.DEFAULT */ --toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */ --editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */ - --terminal-height: 155px; /* TERMINAL_HEIGHT.DEFAULT */ + --terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */ } .sidebar-container { 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) => (
(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx index eeda17943..cf6baf554 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx @@ -3,8 +3,9 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import clsx from 'clsx' -import { ChevronDown, RepeatIcon, SplitIcon } from 'lucide-react' +import { RepeatIcon, SplitIcon } from 'lucide-react' import { useShallow } from 'zustand/react/shallow' +import { ChevronDown } from '@/components/emcn' import { FieldItem, type SchemaField, @@ -115,9 +116,8 @@ function ConnectionItem({ {hasFields && ( )} 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..3362e28de --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx @@ -0,0 +1,121 @@ +'use client' + +import { memo } from 'react' +import clsx from 'clsx' +import { Filter } from 'lucide-react' +import { + Button, + Popover, + PopoverContent, + PopoverDivider, + PopoverItem, + PopoverScrollArea, + PopoverSection, + PopoverTrigger, +} from '@/components/emcn' +import type { + BlockInfo, + TerminalFilters, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' +import { getBlockIcon } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' + +/** + * Props for the FilterPopover component + */ +export interface FilterPopoverProps { + open: boolean + onOpenChange: (open: boolean) => void + filters: TerminalFilters + toggleStatus: (status: 'error' | 'info') => void + toggleBlock: (blockId: string) => void + uniqueBlocks: BlockInfo[] + hasActiveFilters: boolean +} + +/** + * Filter popover component used in terminal header and output panel + */ +export const FilterPopover = memo(function FilterPopover({ + open, + onOpenChange, + filters, + toggleStatus, + toggleBlock, + uniqueBlocks, + hasActiveFilters, +}: FilterPopoverProps) { + return ( + + + + + 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} + + ) + })} + + + )} + + + ) +}) 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 909a6f743..b230b8196 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,5 @@ -export { LogRowContextMenu } from './log-row-context-menu' -export { OutputContextMenu } from './output-context-menu' +export { FilterPopover, type FilterPopoverProps } from './filter-popover' +export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu' +export { OutputPanel, type OutputPanelProps } from './output-panel' +export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display' +export { ToggleButton, type ToggleButtonProps } from './toggle-button' 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 72% 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..be911a3c0 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 @@ -30,19 +23,16 @@ interface LogRowContextMenuProps { filters: TerminalFilters onFilterByBlock: (blockId: string) => void onFilterByStatus: (status: 'error' | 'info') => void - onFilterByRunId: (runId: string) => void onCopyRunId: (runId: string) => void - onClearFilters: () => void onClearConsole: () => void onFixInCopilot: (entry: ConsoleEntry) => void - hasActiveFilters: boolean } /** * Context menu for terminal log rows (left side). * Displays filtering options based on the selected row's properties. */ -export function LogRowContextMenu({ +export const LogRowContextMenu = memo(function LogRowContextMenu({ isOpen, position, menuRef, @@ -51,19 +41,15 @@ export function LogRowContextMenu({ filters, onFilterByBlock, onFilterByStatus, - onFilterByRunId, onCopyRunId, - onClearFilters, onClearConsole, onFixInCopilot, - hasActiveFilters, }: LogRowContextMenuProps) { const hasRunId = entry?.executionId != null const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false const entryStatus = entry?.success ? 'info' : 'error' const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false - const isRunIdFiltered = entry?.executionId ? filters.runIds.has(entry.executionId) : false return ( Filter by Status - {hasRunId && ( - { - onFilterByRunId(entry.executionId!) - onClose() - }} - > - Filter by Run ID - - )} )} - {/* Clear filters */} - {hasActiveFilters && ( - { - onClearFilters() - onClose() - }} - > - Clear All Filters - - )} - {/* Destructive action */} - {(entry || hasActiveFilters) && } + {entry && } { onClearConsole() @@ -173,4 +136,4 @@ export function LogRowContextMenu({ ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx similarity index 82% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-context-menu.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx index 2cb59f9f9..0b3288cda 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/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 @@ -22,6 +18,8 @@ interface OutputContextMenuProps { onCopySelection: () => void onCopyAll: () => void onSearch: () => void + structuredView: boolean + onToggleStructuredView: () => void wrapText: boolean onToggleWrap: () => void openOnRun: boolean @@ -34,7 +32,7 @@ interface OutputContextMenuProps { * Context menu for terminal output panel (right side). * Displays copy, search, and display options for the code viewer. */ -export function OutputContextMenu({ +export const OutputContextMenu = memo(function OutputContextMenu({ isOpen, position, menuRef, @@ -42,6 +40,8 @@ export function OutputContextMenu({ onCopySelection, onCopyAll, onSearch, + structuredView, + onToggleStructuredView, wrapText, onToggleWrap, openOnRun, @@ -96,6 +96,9 @@ export function OutputContextMenu({ {/* Display settings - toggles don't close menu */} + + Structured View + Wrap Text @@ -116,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 new file mode 100644 index 000000000..62d404d7a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx @@ -0,0 +1,913 @@ +'use client' + +import type React from 'react' +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { List, type RowComponentProps, useListRef } from 'react-window' +import { Badge, ChevronDown } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' + +type ValueType = 'null' | 'undefined' | 'array' | 'string' | 'number' | 'boolean' | 'object' +type BadgeVariant = 'green' | 'blue' | 'orange' | 'purple' | 'gray' | 'red' + +interface NodeEntry { + key: string + value: unknown + path: string +} + +/** + * Search context for structured output tree. + */ +interface SearchContextValue { + query: string + pathToMatchIndices: Map +} + +const SearchContext = createContext(null) + +/** + * Configuration for virtualized rendering. + */ +const CONFIG = { + ROW_HEIGHT: 22, + INDENT_PER_LEVEL: 12, + BASE_PADDING: 20, + MAX_SEARCH_DEPTH: 100, + OVERSCAN_COUNT: 10, + VIRTUALIZATION_THRESHOLD: 200, +} as const + +const BADGE_VARIANTS: Record = { + string: 'green', + number: 'blue', + boolean: 'orange', + array: 'purple', + null: 'gray', + undefined: 'gray', + object: 'gray', +} as const + +/** + * Styling constants matching the original non-virtualized implementation. + */ +const STYLES = { + row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] rounded-[8px] px-[6px] -mx-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]', + chevron: + 'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]', + keyName: + 'font-medium text-[13px] text-[var(--text-primary)] group-hover:text-[var(--text-primary)]', + badge: 'rounded-[4px] px-[4px] py-[0px] text-[11px]', + summary: 'text-[12px] text-[var(--text-tertiary)]', + indent: + 'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]', + value: 'min-w-0 py-[2px] text-[13px] text-[var(--text-primary)]', + emptyValue: 'py-[2px] text-[13px] text-[var(--text-tertiary)]', + matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40', + currentMatchHighlight: 'bg-orange-400', +} as const + +const EMPTY_MATCH_INDICES: number[] = [] + +function getTypeLabel(value: unknown): ValueType { + if (value === null) return 'null' + if (value === undefined) return 'undefined' + if (Array.isArray(value)) return 'array' + return typeof value as ValueType +} + +function formatPrimitive(value: unknown): string { + if (value === null) return 'null' + if (value === undefined) return 'undefined' + return String(value) +} + +function isPrimitive(value: unknown): value is null | undefined | string | number | boolean { + return value === null || value === undefined || typeof value !== 'object' +} + +function isEmpty(value: unknown): boolean { + if (Array.isArray(value)) return value.length === 0 + if (typeof value === 'object' && value !== null) return Object.keys(value).length === 0 + return false +} + +function extractErrorMessage(data: unknown): string { + if (typeof data === 'string') return data + if (data instanceof Error) return data.message + if (typeof data === 'object' && data !== null && 'message' in data) { + return String((data as { message: unknown }).message) + } + return JSON.stringify(data, null, 2) +} + +function buildEntries(value: unknown, basePath: string): NodeEntry[] { + if (Array.isArray(value)) { + return value.map((item, i) => ({ key: String(i), value: item, path: `${basePath}[${i}]` })) + } + return Object.entries(value as Record).map(([k, v]) => ({ + key: k, + value: v, + path: `${basePath}.${k}`, + })) +} + +function getCollapsedSummary(value: unknown): string | null { + if (Array.isArray(value)) { + const len = value.length + return `${len} item${len !== 1 ? 's' : ''}` + } + if (typeof value === 'object' && value !== null) { + const count = Object.keys(value).length + return `${count} key${count !== 1 ? 's' : ''}` + } + return null +} + +function computeInitialPaths(data: unknown, isError: boolean): Set { + if (isError) return new Set(['root.error']) + if (!data || typeof data !== 'object') return new Set() + const entries = Array.isArray(data) + ? data.map((_, i) => `root[${i}]`) + : Object.keys(data).map((k) => `root.${k}`) + return new Set(entries) +} + +function getAncestorPaths(path: string): string[] { + const ancestors: string[] = [] + let current = path + + while (current.includes('.') || current.includes('[')) { + const splitPoint = Math.max(current.lastIndexOf('.'), current.lastIndexOf('[')) + if (splitPoint <= 0) break + current = current.slice(0, splitPoint) + if (current !== 'root') ancestors.push(current) + } + + return ancestors +} + +function findTextMatches(text: string, query: string): Array<[number, number]> { + if (!query) return [] + + const matches: Array<[number, number]> = [] + const lowerText = text.toLowerCase() + const lowerQuery = query.toLowerCase() + let pos = 0 + + while (pos < lowerText.length) { + const idx = lowerText.indexOf(lowerQuery, pos) + if (idx === -1) break + matches.push([idx, idx + query.length]) + pos = idx + 1 + } + + return matches +} + +function addPrimitiveMatches(value: unknown, path: string, query: string, matches: string[]): void { + const text = formatPrimitive(value) + const count = findTextMatches(text, query).length + for (let i = 0; i < count; i++) { + matches.push(path) + } +} + +function collectAllMatchPaths(data: unknown, query: string, basePath: string, depth = 0): string[] { + if (!query || depth > CONFIG.MAX_SEARCH_DEPTH) return [] + + const matches: string[] = [] + + if (isPrimitive(data)) { + addPrimitiveMatches(data, `${basePath}.value`, query, matches) + return matches + } + + for (const entry of buildEntries(data, basePath)) { + if (isPrimitive(entry.value)) { + addPrimitiveMatches(entry.value, entry.path, query, matches) + } else { + matches.push(...collectAllMatchPaths(entry.value, query, entry.path, depth + 1)) + } + } + + return matches +} + +function buildPathToIndicesMap(matchPaths: string[]): Map { + const map = new Map() + matchPaths.forEach((path, globalIndex) => { + const existing = map.get(path) + if (existing) { + existing.push(globalIndex) + } else { + map.set(path, [globalIndex]) + } + }) + return map +} + +/** + * Renders text with search highlights using segments. + */ +function renderHighlightedSegments( + text: string, + query: string, + matchIndices: number[], + currentMatchIndex: number, + path: string +): React.ReactNode { + if (!query || matchIndices.length === 0) return text + + const textMatches = findTextMatches(text, query) + if (textMatches.length === 0) return text + + const segments: React.ReactNode[] = [] + let lastEnd = 0 + + textMatches.forEach(([start, end], i) => { + const globalIndex = matchIndices[i] + const isCurrent = globalIndex === currentMatchIndex + + if (start > lastEnd) { + segments.push({text.slice(lastEnd, start)}) + } + + segments.push( + + {text.slice(start, end)} + + ) + lastEnd = end + }) + + if (lastEnd < text.length) { + segments.push({text.slice(lastEnd)}) + } + + return <>{segments} +} + +interface HighlightedTextProps { + text: string + matchIndices: number[] + path: string + currentMatchIndex: number +} + +/** + * Renders text with search highlights for non-virtualized mode. + * Accepts currentMatchIndex as prop to ensure re-render when it changes. + */ +const HighlightedText = memo(function HighlightedText({ + text, + matchIndices, + path, + currentMatchIndex, +}: HighlightedTextProps) { + const searchContext = useContext(SearchContext) + + if (!searchContext || matchIndices.length === 0) return <>{text} + + return ( + <> + {renderHighlightedSegments(text, searchContext.query, matchIndices, currentMatchIndex, path)} + + ) +}) + +interface StructuredNodeProps { + name: string + value: unknown + path: string + expandedPaths: Set + onToggle: (path: string) => void + wrapText: boolean + currentMatchIndex: number + isError?: boolean +} + +/** + * Recursive node component for non-virtualized rendering. + * Preserves exact original styling with border-left tree lines. + */ +const StructuredNode = memo(function StructuredNode({ + name, + value, + path, + expandedPaths, + onToggle, + wrapText, + currentMatchIndex, + isError = false, +}: StructuredNodeProps) { + const searchContext = useContext(SearchContext) + const type = getTypeLabel(value) + const isPrimitiveValue = isPrimitive(value) + const isEmptyValue = !isPrimitiveValue && isEmpty(value) + const isExpanded = expandedPaths.has(path) + + const handleToggle = useCallback(() => onToggle(path), [onToggle, path]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleToggle() + } + }, + [handleToggle] + ) + + const childEntries = useMemo( + () => (isPrimitiveValue || isEmptyValue ? [] : buildEntries(value, path)), + [value, isPrimitiveValue, isEmptyValue, path] + ) + + const collapsedSummary = useMemo( + () => (isPrimitiveValue ? null : getCollapsedSummary(value)), + [value, isPrimitiveValue] + ) + + const badgeVariant = isError ? 'red' : BADGE_VARIANTS[type] + const valueText = isPrimitiveValue ? formatPrimitive(value) : '' + const matchIndices = searchContext?.pathToMatchIndices.get(path) ?? EMPTY_MATCH_INDICES + + return ( +
+
+ {name} + + {type} + + {!isExpanded && collapsedSummary && ( + {collapsedSummary} + )} + +
+ + {isExpanded && ( +
+ {isPrimitiveValue ? ( +
+ +
+ ) : isEmptyValue ? ( +
{Array.isArray(value) ? '[]' : '{}'}
+ ) : ( + childEntries.map((entry) => ( + + )) + )} +
+ )} +
+ ) +}) + +/** + * Flattened row for virtualization. + */ +interface FlatRow { + path: string + key: string + value: unknown + depth: number + type: 'header' | 'value' | 'empty' + valueType: ValueType + isExpanded: boolean + isError: boolean + collapsedSummary: string | null + displayText: string + matchIndices: number[] +} + +/** + * Flattens the tree into rows for virtualization. + */ +function flattenTree( + data: unknown, + expandedPaths: Set, + pathToMatchIndices: Map, + isError: boolean +): FlatRow[] { + const rows: FlatRow[] = [] + + if (isError) { + const errorText = extractErrorMessage(data) + const isExpanded = expandedPaths.has('root.error') + + rows.push({ + path: 'root.error', + key: 'error', + value: errorText, + depth: 0, + type: 'header', + valueType: 'string', + isExpanded, + isError: true, + collapsedSummary: null, + displayText: '', + matchIndices: [], + }) + + if (isExpanded) { + rows.push({ + path: 'root.error.value', + key: '', + value: errorText, + depth: 1, + type: 'value', + valueType: 'string', + isExpanded: false, + isError: true, + collapsedSummary: null, + displayText: errorText, + matchIndices: pathToMatchIndices.get('root.error') ?? [], + }) + } + + return rows + } + + function processNode(key: string, value: unknown, path: string, depth: number): void { + const valueType = getTypeLabel(value) + const isPrimitiveValue = isPrimitive(value) + const isEmptyValue = !isPrimitiveValue && isEmpty(value) + const isExpanded = expandedPaths.has(path) + const collapsedSummary = isPrimitiveValue ? null : getCollapsedSummary(value) + + rows.push({ + path, + key, + value, + depth, + type: 'header', + valueType, + isExpanded, + isError: false, + collapsedSummary, + displayText: '', + matchIndices: [], + }) + + if (isExpanded) { + if (isPrimitiveValue) { + rows.push({ + path: `${path}.value`, + key: '', + value, + depth: depth + 1, + type: 'value', + valueType, + isExpanded: false, + isError: false, + collapsedSummary: null, + displayText: formatPrimitive(value), + matchIndices: pathToMatchIndices.get(path) ?? [], + }) + } else if (isEmptyValue) { + rows.push({ + path: `${path}.empty`, + key: '', + value, + depth: depth + 1, + type: 'empty', + valueType, + isExpanded: false, + isError: false, + collapsedSummary: null, + displayText: Array.isArray(value) ? '[]' : '{}', + matchIndices: [], + }) + } else { + for (const entry of buildEntries(value, path)) { + processNode(entry.key, entry.value, entry.path, depth + 1) + } + } + } + } + + if (isPrimitive(data)) { + processNode('value', data, 'root.value', 0) + } else if (data && typeof data === 'object') { + for (const entry of buildEntries(data, 'root')) { + processNode(entry.key, entry.value, entry.path, 0) + } + } + + return rows +} + +/** + * Counts total visible rows for determining virtualization threshold. + */ +function countVisibleRows(data: unknown, expandedPaths: Set, isError: boolean): number { + if (isError) return expandedPaths.has('root.error') ? 2 : 1 + + let count = 0 + + function countNode(value: unknown, path: string): void { + count++ + if (!expandedPaths.has(path)) return + + if (isPrimitive(value) || isEmpty(value)) { + count++ + } else { + for (const entry of buildEntries(value, path)) { + countNode(entry.value, entry.path) + } + } + } + + if (isPrimitive(data)) { + countNode(data, 'root.value') + } else if (data && typeof data === 'object') { + for (const entry of buildEntries(data, 'root')) { + countNode(entry.value, entry.path) + } + } + + return count +} + +interface VirtualizedRowProps { + rows: FlatRow[] + onToggle: (path: string) => void + wrapText: boolean + searchQuery: string + currentMatchIndex: number +} + +/** + * Virtualized row component for large data sets. + */ +function VirtualizedRow({ index, style, ...props }: RowComponentProps) { + const { rows, onToggle, wrapText, searchQuery, currentMatchIndex } = props + const row = rows[index] + const paddingLeft = CONFIG.BASE_PADDING + row.depth * CONFIG.INDENT_PER_LEVEL + + if (row.type === 'header') { + const badgeVariant = row.isError ? 'red' : BADGE_VARIANTS[row.valueType] + + return ( +
+
onToggle(row.path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle(row.path) + } + }} + role='button' + tabIndex={0} + aria-expanded={row.isExpanded} + > + + {row.key} + + + {row.valueType} + + {!row.isExpanded && row.collapsedSummary && ( + {row.collapsedSummary} + )} + +
+
+ ) + } + + if (row.type === 'empty') { + return ( +
+
{row.displayText}
+
+ ) + } + + return ( +
+
+ {renderHighlightedSegments( + row.displayText, + searchQuery, + row.matchIndices, + currentMatchIndex, + row.path + )} +
+
+ ) +} + +export interface StructuredOutputProps { + data: unknown + wrapText?: boolean + isError?: boolean + isRunning?: boolean + className?: string + searchQuery?: string + currentMatchIndex?: number + onMatchCountChange?: (count: number) => void + contentRef?: React.RefObject +} + +/** + * Renders structured data as nested collapsible blocks. + * Uses virtualization for large data sets (>200 visible rows) while + * preserving exact original styling for smaller data sets. + */ +export const StructuredOutput = memo(function StructuredOutput({ + data, + wrapText = true, + isError = false, + isRunning = false, + className, + searchQuery, + currentMatchIndex = 0, + onMatchCountChange, + contentRef, +}: StructuredOutputProps) { + const [expandedPaths, setExpandedPaths] = useState>(() => + computeInitialPaths(data, isError) + ) + const prevDataRef = useRef(data) + const prevIsErrorRef = useRef(isError) + const internalRef = useRef(null) + const listRef = useListRef(null) + const [containerHeight, setContainerHeight] = useState(400) + + const setContainerRef = useCallback( + (node: HTMLDivElement | null) => { + ;(internalRef as React.MutableRefObject).current = node + if (contentRef) { + ;(contentRef as React.MutableRefObject).current = node + } + }, + [contentRef] + ) + + // Measure container height + useEffect(() => { + const container = internalRef.current?.parentElement + if (!container) return + + const updateHeight = () => setContainerHeight(container.clientHeight) + updateHeight() + + const resizeObserver = new ResizeObserver(updateHeight) + resizeObserver.observe(container) + return () => resizeObserver.disconnect() + }, []) + + // Reset expanded paths when data changes + useEffect(() => { + if (prevDataRef.current !== data || prevIsErrorRef.current !== isError) { + prevDataRef.current = data + prevIsErrorRef.current = isError + setExpandedPaths(computeInitialPaths(data, isError)) + } + }, [data, isError]) + + const allMatchPaths = useMemo(() => { + if (!searchQuery) return [] + if (isError) { + const errorText = extractErrorMessage(data) + const count = findTextMatches(errorText, searchQuery).length + return Array(count).fill('root.error') as string[] + } + return collectAllMatchPaths(data, searchQuery, 'root') + }, [data, searchQuery, isError]) + + useEffect(() => { + onMatchCountChange?.(allMatchPaths.length) + }, [allMatchPaths.length, onMatchCountChange]) + + const pathToMatchIndices = useMemo(() => buildPathToIndicesMap(allMatchPaths), [allMatchPaths]) + + // Auto-expand to current match + useEffect(() => { + if ( + allMatchPaths.length === 0 || + currentMatchIndex < 0 || + currentMatchIndex >= allMatchPaths.length + ) { + return + } + + const currentPath = allMatchPaths[currentMatchIndex] + const pathsToExpand = [currentPath, ...getAncestorPaths(currentPath)] + + setExpandedPaths((prev) => { + if (pathsToExpand.every((p) => prev.has(p))) return prev + const next = new Set(prev) + pathsToExpand.forEach((p) => next.add(p)) + return next + }) + }, [currentMatchIndex, allMatchPaths]) + + const handleToggle = useCallback((path: string) => { + setExpandedPaths((prev) => { + const next = new Set(prev) + if (next.has(path)) { + next.delete(path) + } else { + next.add(path) + } + return next + }) + }, []) + + const rootEntries = useMemo(() => { + if (isPrimitive(data)) return [{ key: 'value', value: data, path: 'root.value' }] + return buildEntries(data, 'root') + }, [data]) + + const searchContextValue = useMemo(() => { + if (!searchQuery) return null + return { query: searchQuery, pathToMatchIndices } + }, [searchQuery, pathToMatchIndices]) + + const visibleRowCount = useMemo( + () => countVisibleRows(data, expandedPaths, isError), + [data, expandedPaths, isError] + ) + const useVirtualization = visibleRowCount > CONFIG.VIRTUALIZATION_THRESHOLD + + const flatRows = useMemo(() => { + if (!useVirtualization) return [] + return flattenTree(data, expandedPaths, pathToMatchIndices, isError) + }, [data, expandedPaths, pathToMatchIndices, isError, useVirtualization]) + + // Scroll to match (virtualized) + useEffect(() => { + if (!useVirtualization || allMatchPaths.length === 0 || !listRef.current) return + + const currentPath = allMatchPaths[currentMatchIndex] + const targetPath = currentPath.endsWith('.value') ? currentPath : `${currentPath}.value` + const rowIndex = flatRows.findIndex((r) => r.path === targetPath || r.path === currentPath) + + if (rowIndex !== -1) { + listRef.current.scrollToRow({ index: rowIndex, align: 'center' }) + } + }, [currentMatchIndex, allMatchPaths, flatRows, listRef, useVirtualization]) + + // Scroll to match (non-virtualized) + useEffect(() => { + if (useVirtualization || allMatchPaths.length === 0) return + + const rafId = requestAnimationFrame(() => { + const match = internalRef.current?.querySelector( + `[data-match-index="${currentMatchIndex}"]` + ) as HTMLElement | null + match?.scrollIntoView({ block: 'center', behavior: 'smooth' }) + }) + + return () => cancelAnimationFrame(rafId) + }, [currentMatchIndex, allMatchPaths.length, expandedPaths, useVirtualization]) + + const containerClass = cn('flex flex-col pl-[20px]', wrapText && 'overflow-x-hidden', className) + const virtualizedContainerClass = cn('relative', wrapText && 'overflow-x-hidden', className) + const listClass = wrapText ? 'overflow-x-hidden' : 'overflow-x-auto' + + // Running state + if (isRunning && data === undefined) { + return ( +
+
+ running + + Running + +
+
+ ) + } + + // Empty state + if (rootEntries.length === 0 && !isError) { + return ( +
+ null +
+ ) + } + + // Virtualized rendering + if (useVirtualization) { + return ( +
+ +
+ ) + } + + // Non-virtualized rendering (preserves exact original styling) + if (isError) { + return ( + +
+ +
+
+ ) + } + + return ( + +
+ {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 new file mode 100644 index 000000000..20fe06c25 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts @@ -0,0 +1,4 @@ +export { OutputContextMenu, type OutputContextMenuProps } from './components/output-context-menu' +export { StructuredOutput, type StructuredOutputProps } from './components/structured-output' +export type { OutputPanelProps } from './output-panel' +export { OutputPanel } from './output-panel' 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 new file mode 100644 index 000000000..a9292f706 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -0,0 +1,643 @@ +'use client' + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import clsx from 'clsx' +import { + ArrowDown, + ArrowDownToLine, + ArrowUp, + Check, + Clipboard, + Database, + MoreHorizontal, + Palette, + Pause, + Search, + Trash2, + X, +} from 'lucide-react' +import Link from 'next/link' +import { + Button, + Code, + Input, + Popover, + PopoverContent, + PopoverItem, + PopoverTrigger, + Tooltip, +} from '@/components/emcn' +import { FilterPopover } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover' +import { OutputContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu' +import { StructuredOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output' +import { ToggleButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button' +import type { + BlockInfo, + TerminalFilters, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' +import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' +import type { ConsoleEntry } from '@/stores/terminal' +import { useTerminalStore } from '@/stores/terminal' + +interface OutputCodeContentProps { + code: string + language: 'javascript' | 'json' + wrapText: boolean + searchQuery: string | undefined + currentMatchIndex: number + onMatchCountChange: (count: number) => void + contentRef: React.RefObject +} + +const OutputCodeContent = React.memo(function OutputCodeContent({ + code, + language, + wrapText, + searchQuery, + currentMatchIndex, + onMatchCountChange, + contentRef, +}: OutputCodeContentProps) { + return ( + + ) +}) + +/** + * Props for the OutputPanel component + * Store-backed settings (wrapText, openOnRun, structuredView, outputPanelWidth) + * are accessed directly from useTerminalStore to reduce prop drilling. + */ +export interface OutputPanelProps { + selectedEntry: ConsoleEntry + handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void + handleHeaderClick: () => void + isExpanded: boolean + expandToLastHeight: () => void + showInput: boolean + setShowInput: (show: boolean) => void + hasInputData: boolean + isPlaygroundEnabled: boolean + shouldShowTrainingButton: boolean + isTraining: boolean + handleTrainingClick: (e: React.MouseEvent) => void + showCopySuccess: boolean + handleCopy: () => void + filteredEntries: ConsoleEntry[] + handleExportConsole: (e: React.MouseEvent) => void + hasActiveFilters: boolean + handleClearConsole: (e: React.MouseEvent) => void + shouldShowCodeDisplay: boolean + outputDataStringified: string + outputData: unknown + handleClearConsoleFromMenu: () => void + filters: TerminalFilters + toggleBlock: (blockId: string) => void + toggleStatus: (status: 'error' | 'info') => void + uniqueBlocks: BlockInfo[] +} + +/** + * Output panel component that manages its own search state. + * Accesses store-backed settings directly to reduce prop drilling. + */ +export const OutputPanel = React.memo(function OutputPanel({ + selectedEntry, + handleOutputPanelResizeMouseDown, + handleHeaderClick, + isExpanded, + expandToLastHeight, + showInput, + setShowInput, + hasInputData, + isPlaygroundEnabled, + shouldShowTrainingButton, + isTraining, + handleTrainingClick, + showCopySuccess, + handleCopy, + filteredEntries, + handleExportConsole, + hasActiveFilters, + handleClearConsole, + shouldShowCodeDisplay, + outputDataStringified, + outputData, + handleClearConsoleFromMenu, + filters, + toggleBlock, + toggleStatus, + uniqueBlocks, +}: OutputPanelProps) { + // Access store-backed settings directly to reduce prop drilling + const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth) + const wrapText = useTerminalStore((state) => state.wrapText) + const setWrapText = useTerminalStore((state) => state.setWrapText) + const openOnRun = useTerminalStore((state) => state.openOnRun) + const setOpenOnRun = useTerminalStore((state) => state.setOpenOnRun) + const structuredView = useTerminalStore((state) => state.structuredView) + const setStructuredView = useTerminalStore((state) => state.setStructuredView) + + const outputContentRef = useRef(null) + const [filtersOpen, setFiltersOpen] = useState(false) + const [outputOptionsOpen, setOutputOptionsOpen] = useState(false) + const { + isSearchActive: isOutputSearchActive, + searchQuery: outputSearchQuery, + setSearchQuery: setOutputSearchQuery, + matchCount, + currentMatchIndex, + activateSearch: activateOutputSearch, + closeSearch: closeOutputSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef: outputSearchInputRef, + } = useCodeViewerFeatures({ + contentRef: outputContentRef, + externalWrapText: wrapText, + onWrapTextChange: setWrapText, + }) + + // Context menu state for output panel + const [hasSelection, setHasSelection] = useState(false) + const [storedSelectionText, setStoredSelectionText] = useState('') + const { + isOpen: isOutputMenuOpen, + position: outputMenuPosition, + menuRef: outputMenuRef, + handleContextMenu: handleOutputContextMenu, + closeMenu: closeOutputMenu, + } = useContextMenu() + + const handleOutputPanelContextMenu = useCallback( + (e: React.MouseEvent) => { + const selection = window.getSelection() + const selectionText = selection?.toString() || '' + setStoredSelectionText(selectionText) + setHasSelection(selectionText.length > 0) + handleOutputContextMenu(e) + }, + [handleOutputContextMenu] + ) + + const handleCopySelection = useCallback(() => { + if (storedSelectionText) { + navigator.clipboard.writeText(storedSelectionText) + } + }, [storedSelectionText]) + + // Memoized callbacks to avoid inline arrow functions + const handleToggleStructuredView = useCallback(() => { + setStructuredView(!structuredView) + }, [structuredView, setStructuredView]) + + const handleToggleWrapText = useCallback(() => { + setWrapText(!wrapText) + }, [wrapText, setWrapText]) + + const handleToggleOpenOnRun = useCallback(() => { + setOpenOnRun(!openOnRun) + }, [openOnRun, setOpenOnRun]) + + const handleCopyClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + handleCopy() + }, + [handleCopy] + ) + + const handleSearchClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + activateOutputSearch() + }, + [activateOutputSearch] + ) + + const handleCloseSearchClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + closeOutputSearch() + }, + [closeOutputSearch] + ) + + const handleOutputButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!isExpanded) { + expandToLastHeight() + } + if (showInput) setShowInput(false) + }, + [isExpanded, expandToLastHeight, showInput, setShowInput] + ) + + const handleInputButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!isExpanded) { + expandToLastHeight() + } + setShowInput(true) + }, + [isExpanded, expandToLastHeight, setShowInput] + ) + + const handleToggleButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + handleHeaderClick() + }, + [handleHeaderClick] + ) + + /** + * Track text selection state for context menu. + * Skip updates when the context menu is open to prevent the selection + * state from changing mid-click (which would disable the copy button). + */ + useEffect(() => { + const handleSelectionChange = () => { + if (isOutputMenuOpen) return + + const selection = window.getSelection() + setHasSelection(Boolean(selection && selection.toString().length > 0)) + } + + document.addEventListener('selectionchange', handleSelectionChange) + return () => document.removeEventListener('selectionchange', handleSelectionChange) + }, [isOutputMenuOpen]) + + // Memoize the search query for structured output to avoid re-renders + const structuredSearchQuery = useMemo( + () => (isOutputSearchActive ? outputSearchQuery : undefined), + [isOutputSearchActive, outputSearchQuery] + ) + + return ( + <> +
+ {/* Horizontal Resize Handle */} +
+ + {/* Header */} +
+
+ + {hasInputData && ( + + )} +
+
+ {/* Unified filter popover */} + {filteredEntries.length > 0 && ( + + )} + + {isOutputSearchActive ? ( + + + + + + Close search + + + ) : ( + + + + + + Search + + + )} + + {isPlaygroundEnabled && ( + + + + + + + + Component Playground + + + )} + + {shouldShowTrainingButton && ( + + + + + + {isTraining ? 'Stop Training' : 'Train Copilot'} + + + )} + + + + + + + {showCopySuccess ? 'Copied' : 'Copy output'} + + + {filteredEntries.length > 0 && ( + <> + + + + + + Download CSV + + + + + + + + Clear console + + + + )} + + + + + e.stopPropagation()} + style={{ minWidth: '140px', maxWidth: '160px' }} + className='gap-[2px]' + > + + Structured view + + + Wrap text + + + Open on run + + + + +
+
+ + {/* Search Overlay */} + {isOutputSearchActive && ( +
e.stopPropagation()} + data-toolbar-root + data-search-active='true' + > + setOutputSearchQuery(e.target.value)} + placeholder='Search...' + className='mr-[2px] h-[23px] w-[94px] text-[12px]' + /> + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results'} + + + + +
+ )} + + {/* Content */} +
+ {shouldShowCodeDisplay ? ( + + ) : structuredView ? ( + + ) : ( + + )} +
+
+ + {/* Output Panel Context Menu */} + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts new file mode 100644 index 000000000..0bc435a9a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts @@ -0,0 +1 @@ +export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx new file mode 100644 index 000000000..fa54725e2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx @@ -0,0 +1,43 @@ +'use client' + +import { memo } from 'react' +import { Badge } from '@/components/emcn' +import { BADGE_STYLE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' + +/** + * Running badge component - displays a consistent "Running" indicator + */ +export const RunningBadge = memo(function RunningBadge() { + return ( + + 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/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-output-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts index d3d9f59e9..2c5fe4323 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts @@ -1,9 +1,7 @@ import { useCallback, useEffect, useState } from 'react' -import { OUTPUT_PANEL_WIDTH } from '@/stores/constants' +import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants' import { useTerminalStore } from '@/stores/terminal' -const BLOCK_COLUMN_WIDTH = 240 - export function useOutputPanelResize() { const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth) const [isResizing, setIsResizing] = useState(false) @@ -25,7 +23,7 @@ export function useOutputPanelResize() { const newWidth = window.innerWidth - e.clientX - panelWidth const terminalWidth = window.innerWidth - sidebarWidth - panelWidth - const maxWidth = terminalWidth - BLOCK_COLUMN_WIDTH + const maxWidth = terminalWidth - TERMINAL_BLOCK_COLUMN_WIDTH const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth)) setOutputPanelWidth(clampedWidth) 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..1807828f4 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. @@ -31,7 +15,6 @@ export function useTerminalFilters() { const [filters, setFilters] = useState({ blockIds: new Set(), statuses: new Set(), - runIds: new Set(), }) const [sortConfig, setSortConfig] = useState({ @@ -69,21 +52,6 @@ export function useTerminalFilters() { }) }, []) - /** - * Toggles a run ID filter - */ - const toggleRunId = useCallback((runId: string) => { - setFilters((prev) => { - const newRunIds = new Set(prev.runIds) - if (newRunIds.has(runId)) { - newRunIds.delete(runId) - } else { - newRunIds.add(runId) - } - return { ...prev, runIds: newRunIds } - }) - }, []) - /** * Toggles sort direction between ascending and descending */ @@ -101,7 +69,6 @@ export function useTerminalFilters() { setFilters({ blockIds: new Set(), statuses: new Set(), - runIds: new Set(), }) }, []) @@ -109,7 +76,7 @@ export function useTerminalFilters() { * Checks if any filters are active */ const hasActiveFilters = useMemo(() => { - return filters.blockIds.size > 0 || filters.statuses.size > 0 || filters.runIds.size > 0 + return filters.blockIds.size > 0 || filters.statuses.size > 0 }, [filters]) /** @@ -134,14 +101,6 @@ export function useTerminalFilters() { if (!hasStatus) return false } - // Run ID filter - if ( - filters.runIds.size > 0 && - (!entry.executionId || !filters.runIds.has(entry.executionId)) - ) { - return false - } - return true }) } @@ -164,7 +123,6 @@ export function useTerminalFilters() { sortConfig, toggleBlock, toggleStatus, - toggleRunId, toggleSort, clearFilters, hasActiveFilters, 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 bd8334c45..342d5e131 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 @@ -1,57 +1,58 @@ 'use client' -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type React from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { ArrowDown, ArrowDownToLine, ArrowUp, - Check, - ChevronDown, - Clipboard, Database, - Filter, - FilterX, MoreHorizontal, Palette, Pause, - RepeatIcon, - Search, - SplitIcon, Trash2, - X, } from 'lucide-react' import Link from 'next/link' -import { useShallow } from 'zustand/react/shallow' import { - Badge, Button, - Code, - Input, + 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, - OutputContextMenu, + OutputPanel, + StatusDisplay, + ToggleButton, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components' import { useOutputPanelResize, useTerminalFilters, useTerminalResize, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks' +import { ROW_STYLES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' +import { + type EntryNode, + type ExecutionGroup, + flattenBlockEntriesOnly, + formatDuration, + getBlockColor, + getBlockIcon, + groupEntriesByExecution, + isEventFromEditableElement, + type NavigableBlockEntry, + 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 { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants' import { useCopilotTrainingStore } from '@/stores/copilot-training/store' import { openCopilotWithMessage } from '@/stores/notifications/utils' @@ -63,772 +64,420 @@ 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 -}) => ( - -) - -/** - * Truncates execution ID for display as run ID - */ -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 - } - - let el: HTMLElement | null = target - while (el) { - if (isEditable(el)) return true - el = el.parentElement - } - return false -} - -interface OutputCodeContentProps { - code: string - language: 'javascript' | 'json' - wrapText: boolean - searchQuery: string | undefined - currentMatchIndex: number - onMatchCountChange: (count: number) => void - contentRef: React.RefObject -} - -const OutputCodeContent = React.memo(function OutputCodeContent({ - code, - language, - wrapText, - searchQuery, - currentMatchIndex, - onMatchCountChange, - contentRef, -}: OutputCodeContentProps) { return ( - { + e.stopPropagation() + onSelect(entry) + }} + > +
+
+ {BlockIcon && } +
+ + {entry.blockName} + +
+ + + +
+ ) +}) + +/** + * 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 + 1}${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 ( + ) }) /** - * Props for the OutputPanel component + * Execution group row component with dashed separator */ -interface OutputPanelProps { - selectedEntry: ConsoleEntry - outputPanelWidth: number - handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void - handleHeaderClick: () => void - isExpanded: boolean - expandToLastHeight: () => void - showInput: boolean - setShowInput: (show: boolean) => void - hasInputData: boolean - isPlaygroundEnabled: boolean - shouldShowTrainingButton: boolean - isTraining: boolean - handleTrainingClick: (e: React.MouseEvent) => void - showCopySuccess: boolean - handleCopy: () => void - filteredEntries: ConsoleEntry[] - handleExportConsole: (e: React.MouseEvent) => void - hasActiveFilters: boolean - clearFilters: () => void - handleClearConsole: (e: React.MouseEvent) => void - wrapText: boolean - setWrapText: (wrap: boolean) => void - openOnRun: boolean - setOpenOnRun: (open: boolean) => void - outputOptionsOpen: boolean - setOutputOptionsOpen: (open: boolean) => void - shouldShowCodeDisplay: boolean - outputDataStringified: string - handleClearConsoleFromMenu: () => void -} - -/** - * Output panel component that manages its own search state. - */ -const OutputPanel = React.memo(function OutputPanel({ - selectedEntry, - outputPanelWidth, - handleOutputPanelResizeMouseDown, - handleHeaderClick, - isExpanded, - expandToLastHeight, - showInput, - setShowInput, - hasInputData, - isPlaygroundEnabled, - shouldShowTrainingButton, - isTraining, - handleTrainingClick, - showCopySuccess, - handleCopy, - filteredEntries, - handleExportConsole, - hasActiveFilters, - clearFilters, - handleClearConsole, - wrapText, - setWrapText, - openOnRun, - setOpenOnRun, - outputOptionsOpen, - setOutputOptionsOpen, - shouldShowCodeDisplay, - outputDataStringified, - handleClearConsoleFromMenu, -}: OutputPanelProps) { - const outputContentRef = useRef(null) - const { - isSearchActive: isOutputSearchActive, - searchQuery: outputSearchQuery, - setSearchQuery: setOutputSearchQuery, - matchCount, - currentMatchIndex, - activateSearch: activateOutputSearch, - closeSearch: closeOutputSearch, - goToNextMatch, - goToPreviousMatch, - handleMatchCountChange, - searchInputRef: outputSearchInputRef, - } = useCodeViewerFeatures({ - contentRef: outputContentRef, - externalWrapText: wrapText, - onWrapTextChange: setWrapText, - }) - - // Context menu state for output panel - const [hasSelection, setHasSelection] = useState(false) - const [storedSelectionText, setStoredSelectionText] = useState('') - const { - isOpen: isOutputMenuOpen, - position: outputMenuPosition, - menuRef: outputMenuRef, - handleContextMenu: handleOutputContextMenu, - closeMenu: closeOutputMenu, - } = useContextMenu() - - const handleOutputPanelContextMenu = useCallback( - (e: React.MouseEvent) => { - const selection = window.getSelection() - const selectionText = selection?.toString() || '' - setStoredSelectionText(selectionText) - setHasSelection(selectionText.length > 0) - handleOutputContextMenu(e) - }, - [handleOutputContextMenu] - ) - - const handleCopySelection = useCallback(() => { - if (storedSelectionText) { - navigator.clipboard.writeText(storedSelectionText) - } - }, [storedSelectionText]) - - /** - * Track text selection state for context menu. - * Skip updates when the context menu is open to prevent the selection - * state from changing mid-click (which would disable the copy button). - */ - useEffect(() => { - const handleSelectionChange = () => { - if (isOutputMenuOpen) return - - const selection = window.getSelection() - setHasSelection(Boolean(selection && selection.toString().length > 0)) - } - - document.addEventListener('selectionchange', handleSelectionChange) - return () => document.removeEventListener('selectionchange', handleSelectionChange) - }, [isOutputMenuOpen]) - +const ExecutionGroupRow = memo(function ExecutionGroupRow({ + group, + showSeparator, + selectedEntryId, + onSelectEntry, + expandedNodes, + onToggleNode, +}: { + group: ExecutionGroup + showSeparator: boolean + selectedEntryId: string | null + onSelectEntry: (entry: ConsoleEntry) => void + expandedNodes: Set + onToggleNode: (nodeId: string) => void +}) { return ( - <> -
- {/* Horizontal Resize Handle */} -
+
+ {/* Separator between executions */} + {showSeparator &&
} - {/* Header */} -
-
- - {hasInputData && ( - - )} -
-
- {isOutputSearchActive ? ( - - - - - - Close search - - - ) : ( - - - - - - Search - - - )} - - {isPlaygroundEnabled && ( - - - - - - - - Component Playground - - - )} - - {shouldShowTrainingButton && ( - - - - - - {isTraining ? 'Stop Training' : 'Train Copilot'} - - - )} - - - - - - - {showCopySuccess ? 'Copied' : 'Copy output'} - - - {filteredEntries.length > 0 && ( - - - - - - Download CSV - - - )} - {hasActiveFilters && ( - - - - - - Clear filters - - - )} - {filteredEntries.length > 0 && ( - - - - - - Clear console - - - )} - - - - - e.stopPropagation()} - style={{ minWidth: '140px', maxWidth: '160px' }} - className='gap-[2px]' - > - { - e.stopPropagation() - setWrapText(!wrapText) - }} - > - Wrap text - - { - e.stopPropagation() - setOpenOnRun(!openOnRun) - }} - > - Open on run - - - - { - e.stopPropagation() - handleHeaderClick() - }} - /> -
-
- - {/* Search Overlay */} - {isOutputSearchActive && ( -
e.stopPropagation()} - data-toolbar-root - data-search-active='true' - > - setOutputSearchQuery(e.target.value)} - placeholder='Search...' - className='mr-[2px] h-[23px] w-[94px] text-[12px]' - /> - 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' - )} - > - {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results'} - - - - -
- )} - - {/* Content */} -
- {shouldShowCodeDisplay ? ( - - ) : ( - - )} -
+ {/* Entry tree */} +
+ {group.entryTree.map((node) => ( + + ))}
- - {/* Output Panel Context Menu */} - setWrapText(!wrapText)} - openOnRun={openOnRun} - onToggleOpenOnRun={() => setOpenOnRun(!openOnRun)} - onClearConsole={handleClearConsoleFromMenu} - hasSelection={hasSelection} - /> - +
) }) /** * 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 prevEntriesLengthRef = useRef(0) + const logsContainerRef = useRef(null) 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) const openOnRun = useTerminalStore((state) => state.openOnRun) const setOpenOnRun = useTerminalStore((state) => state.setOpenOnRun) - const wrapText = useTerminalStore((state) => state.wrapText) - const setWrapText = useTerminalStore((state) => state.setWrapText) const 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 [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() @@ -844,29 +493,21 @@ export const Terminal = memo(function Terminal() { sortConfig, toggleBlock, toggleStatus, - toggleRunId, toggleSort, clearFilters, filterEntries, 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) @@ -888,6 +529,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 (excludes subflow/iteration container nodes). + * 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 */ @@ -905,75 +566,6 @@ export const Terminal = memo(function Terminal() { return Array.from(blocksMap.values()).sort((a, b) => a.blockName.localeCompare(b.blockName)) }, [allWorkflowEntries]) - /** - * Get unique run IDs from all workflow entries - */ - const uniqueRunIds = useMemo(() => { - const runIdsSet = new Set() - allWorkflowEntries.forEach((entry) => { - if (entry.executionId) { - runIdsSet.add(entry.executionId) - } - }) - return Array.from(runIdsSet).sort() - }, [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. - */ - const colorStateRef = useRef<{ executionIds: string[]; offset: number }>({ - executionIds: [], - offset: 0, - }) - - /** - * 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. - */ - const executionColorMap = useMemo(() => { - const currentIds: string[] = [] - const seen = new Set() - for (let i = allWorkflowEntries.length - 1; i >= 0; i--) { - const execId = allWorkflowEntries[i].executionId - if (execId && !seen.has(execId)) { - currentIds.push(execId) - seen.add(execId) - } - } - - const { executionIds: prevIds, offset: prevOffset } = colorStateRef.current - let newOffset = prevOffset - - if (prevIds.length > 0 && currentIds.length > 0) { - const currentOldest = currentIds[0] - if (prevIds[0] !== currentOldest) { - const trimmedCount = prevIds.indexOf(currentOldest) - if (trimmedCount > 0) { - newOffset = (prevOffset + trimmedCount) % RUN_ID_COLORS.length - } - } - } - - const colorMap = new Map() - for (let i = 0; i < currentIds.length; i++) { - const colorIndex = (newOffset + i) % RUN_ID_COLORS.length - colorMap.set(currentIds[i], RUN_ID_COLORS[colorIndex]) - } - - colorStateRef.current = { executionIds: currentIds, offset: newOffset } - - return colorMap - }, [allWorkflowEntries]) - /** * Check if input data exists for selected entry */ @@ -1008,6 +600,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. @@ -1058,17 +659,74 @@ 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 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] + + // 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 - clicking same entry toggles selection off + */ + const handleSelectEntry = useCallback( + (entry: ConsoleEntry) => { + focusTerminal() + setSelectedEntry((prev) => { + // Disable auto-select on any manual selection/deselection + setAutoSelectEnabled(false) + return prev?.id === entry.id ? null : entry + }) + }, + [focusTerminal] + ) + + /** + * 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 }) }, []) @@ -1085,44 +743,28 @@ 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) + setExpandedNodes(new Set()) } }, [activeWorkflowId, clearWorkflowConsole]) @@ -1144,14 +786,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) @@ -1168,14 +802,6 @@ export const Terminal = memo(function Terminal() { [toggleStatus, closeLogRowMenu] ) - const handleFilterByRunId = useCallback( - (runId: string) => { - toggleRunId(runId) - closeLogRowMenu() - }, - [toggleRunId, closeLogRowMenu] - ) - const handleCopyRunId = useCallback( (runId: string) => { navigator.clipboard.writeText(runId) @@ -1213,13 +839,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([ { @@ -1234,62 +853,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(() => { @@ -1299,97 +896,189 @@ 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) - */ + const scrollEntryIntoView = useCallback((entryId: string) => { + const container = logsContainerRef.current + if (!container) return + const el = container.querySelector(`[data-entry-id="${entryId}"]`) + if (el) { + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + } + }, []) + 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) return + + const newestExecutionId = executionGroups[0].executionId + let lastNavEntry: NavigableBlockEntry | null = null + + for (const navEntry of navigableEntries) { + if (navEntry.executionId === newestExecutionId) { + lastNavEntry = navEntry + } else { + break + } } - prevEntriesLengthRef.current = filteredEntries.length - }, [filteredEntries, autoSelectEnabled]) + if (!lastNavEntry) return + if (selectedEntry?.id === lastNavEntry.entry.id) return + + setSelectedEntry(lastNavEntry.entry) + focusTerminal() + + if (lastNavEntry.parentNodeIds.length > 0) { + setExpandedNodes((prev) => { + const hasAll = lastNavEntry.parentNodeIds.every((id) => prev.has(id)) + if (hasAll) return prev + const next = new Set(prev) + lastNavEntry.parentNodeIds.forEach((id) => next.add(id)) + return next + }) + } + }, [executionGroups, navigableEntries, autoSelectEnabled, selectedEntry?.id, focusTerminal]) + + useEffect(() => { + if (selectedEntry) { + scrollEntryIntoView(selectedEntry.id) + } + }, [selectedEntry?.id, scrollEntryIntoView]) /** - * Handle keyboard navigation through logs - * Disables auto-selection when user manually navigates - * 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 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 + // Common guards 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)) { + const searchOverlay = document.querySelector('[data-toolbar-root][data-search-active="true"]') + if (searchOverlay && activeElement && searchOverlay.contains(activeElement)) { return } - if (!selectedEntry || filteredEntries.length === 0) return + const currentEntry = selectedEntryRef.current + const entries = navigableEntriesRef.current - 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]) - } - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEntry, filteredEntries]) - - /** - * Handle keyboard navigation for input/output toggle - * Left arrow shows output, right arrow shows input - * Only active when the terminal is focused - */ - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle navigation when terminal is focused - if (!isTerminalFocusedRef.current) return - // Ignore when typing/navigating inside editable inputs/editors - if (isEventFromEditableElement(e)) return - - if (!selectedEntry) return - - if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return - - e.preventDefault() - - if (!isExpanded) { - expandToLastHeight() - } - - if (e.key === 'ArrowLeft') { - if (showInput) { - setShowInput(false) + // 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) } } @@ -1397,35 +1086,11 @@ 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 useCodeViewerFeatures) - * 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. + * Closes the output panel if there's not enough space for the minimum width. */ useEffect(() => { const handleResize = () => { @@ -1439,9 +1104,16 @@ 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) { + // Close output panel if there's not enough space for minimum width + if (maxWidth < MIN_OUTPUT_PANEL_WIDTH_PX) { + setAutoSelectEnabled(false) + setSelectedEntry(null) + return + } + + if (outputPanelWidth > maxWidth) { setOutputPanelWidth(Math.max(maxWidth, MIN_OUTPUT_PANEL_WIDTH_PX)) } } @@ -1489,215 +1161,61 @@ 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 && ( @@ -1716,6 +1234,7 @@ export const Terminal = memo(function Terminal() { )} + {shouldShowTrainingButton && ( @@ -1740,26 +1259,7 @@ export const Terminal = memo(function Terminal() { )} - {hasActiveFilters && ( - - - - - - Clear filters - - - )} + {filteredEntries.length > 0 && ( <> @@ -1794,6 +1294,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, index) => ( + 0} + selectedEntryId={selectedEntry?.id || null} + onSelectEntry={handleSelectEntry} + expandedNodes={expandedNodes} + onToggleNode={handleToggleNode} + /> + )) )}
@@ -1939,7 +1367,6 @@ export const Terminal = memo(function Terminal() { {selectedEntry && ( )}
@@ -1978,19 +1403,13 @@ export const Terminal = memo(function Terminal() { position={logRowMenuPosition} menuRef={logRowMenuRef} onClose={closeLogRowMenu} - entry={contextMenuEntry} + entry={selectedEntry} filters={filters} onFilterByBlock={handleFilterByBlock} onFilterByStatus={handleFilterByStatus} - onFilterByRunId={handleFilterByRunId} onCopyRunId={handleCopyRunId} - onClearFilters={() => { - clearFilters() - closeLogRowMenu() - }} onClearConsole={handleClearConsoleFromMenu} onFixInCopilot={handleFixInCopilot} - hasActiveFilters={hasActiveFilters} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts new file mode 100644 index 000000000..a9029f814 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts @@ -0,0 +1,64 @@ +/** + * Terminal filter configuration state + */ +export interface TerminalFilters { + blockIds: Set + statuses: Set<'error' | 'info'> +} + +/** + * Context menu position for positioning floating menus + */ +export interface ContextMenuPosition { + x: number + y: number +} + +/** + * Sort field options for terminal entries + */ +export type SortField = 'timestamp' + +/** + * Sort direction options + */ +export type SortDirection = 'asc' | 'desc' + +/** + * Sort configuration for terminal entries + */ +export interface SortConfig { + field: SortField + direction: SortDirection +} + +/** + * Status type for console entries + */ +export type EntryStatus = 'error' | 'info' + +/** + * Block information for filters + */ +export interface BlockInfo { + blockId: string + blockName: string + blockType: string +} + +/** + * Common row styling classes for terminal components + */ +export const ROW_STYLES = { + base: 'group flex cursor-pointer items-center justify-between gap-[8px] rounded-[8px] px-[6px]', + selected: 'bg-[var(--surface-6)] dark:bg-[var(--surface-5)]', + hover: 'hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]', + nested: + 'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]', + iconButton: '!p-1.5 -m-1.5', +} as const + +/** + * Common badge styling for status badges + */ +export const BADGE_STYLE = 'rounded-[4px] px-[4px] py-[0px] text-[11px]' 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..39d3770b3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts @@ -0,0 +1,452 @@ +import type React from 'react' +import { RepeatIcon, SplitIcon } from 'lucide-react' +import { getBlock } from '@/blocks' +import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants' +import type { ConsoleEntry } from '@/stores/terminal' + +/** + * Subflow colors matching the subflow tool configs + */ +const SUBFLOW_COLORS = { + loop: '#2FB3FF', + parallel: '#FEE12B', +} as const + +/** + * Retrieves the icon component for a given block type + */ +export function getBlockIcon( + blockType: string +): React.ComponentType<{ className?: string }> | null { + const blockConfig = getBlock(blockType) + + if (blockConfig?.icon) { + return blockConfig.icon + } + + if (blockType === 'loop') { + return RepeatIcon + } + + if (blockType === 'parallel') { + return SplitIcon + } + + return null +} + +/** + * Gets the background color for a block type + */ +export function getBlockColor(blockType: string): string { + const blockConfig = getBlock(blockType) + if (blockConfig?.bgColor) { + return blockConfig.bgColor + } + // Use proper subflow colors matching the toolbar configs + if (blockType === 'loop') { + return SUBFLOW_COLORS.loop + } + if (blockType === 'parallel') { + return SUBFLOW_COLORS.parallel + } + return '#6b7280' +} + +/** + * Formats duration from milliseconds to readable format + */ +export function formatDuration(ms?: number): string { + if (ms === undefined || ms === null) return '-' + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +/** + * Determines if a keyboard event originated from a text-editable element + */ +export function isEventFromEditableElement(e: KeyboardEvent): boolean { + const target = e.target as HTMLElement | null + if (!target) return false + + const isEditable = (el: HTMLElement | null): boolean => { + if (!el) return false + if (el instanceof HTMLInputElement) return true + if (el instanceof HTMLTextAreaElement) return true + if ((el as HTMLElement).isContentEditable) return true + const role = el.getAttribute('role') + if (role === 'textbox' || role === 'combobox') return true + return false + } + + let el: HTMLElement | null = target + while (el) { + if (isEditable(el)) return true + el = el.parentElement + } + return false +} + +/** + * Checks if a block type is a subflow (loop or parallel) + */ +export function isSubflowBlockType(blockType: string): boolean { + const lower = blockType?.toLowerCase() || '' + return lower === 'loop' || lower === 'parallel' +} + +/** + * Node type for the tree structure + */ +export type EntryNodeType = 'block' | 'subflow' | 'iteration' + +/** + * Entry node for tree structure - represents a block, subflow, or iteration + */ +export interface EntryNode { + /** The console entry (for blocks) or synthetic entry (for subflows/iterations) */ + entry: ConsoleEntry + /** Child nodes */ + children: EntryNode[] + /** Node type */ + nodeType: EntryNodeType + /** Iteration info for iteration nodes */ + iterationInfo?: { + current: number + total?: number + } +} + +/** + * Execution group interface for grouping entries by execution + */ +export interface ExecutionGroup { + executionId: string + startTime: string + endTime: string + startTimeMs: number + endTimeMs: number + duration: number + status: 'success' | 'error' + /** Flat list of entries (legacy, kept for filters) */ + entries: ConsoleEntry[] + /** Tree structure of entry nodes for nested display */ + entryTree: EntryNode[] +} + +/** + * Iteration group for grouping blocks within the same iteration + */ +interface IterationGroup { + iterationType: string + iterationCurrent: number + iterationTotal?: number + blocks: ConsoleEntry[] + startTimeMs: number +} + +/** + * Builds a tree structure from flat entries. + * Groups iteration entries by (iterationType, iterationCurrent), showing all blocks + * that executed within each iteration. + * Sorts by start time to ensure chronological order. + */ +function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { + // Separate regular blocks from iteration entries + const regularBlocks: ConsoleEntry[] = [] + const iterationEntries: ConsoleEntry[] = [] + + for (const entry of entries) { + if (entry.iterationType && entry.iterationCurrent !== undefined) { + iterationEntries.push(entry) + } else { + regularBlocks.push(entry) + } + } + + // Group iteration entries by (iterationType, iterationCurrent) + const iterationGroupsMap = new Map() + 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 +} + +/** + * Terminal height configuration constants + */ +export const TERMINAL_CONFIG = { + NEAR_MIN_THRESHOLD: 40, + BLOCK_COLUMN_WIDTH_PX: TERMINAL_BLOCK_COLUMN_WIDTH, + HEADER_TEXT_CLASS: 'font-medium text-[var(--text-tertiary)] text-[12px]', +} as const 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 fc9bdbd14..8f622c165 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 @@ -84,7 +84,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 { @@ -875,6 +876,8 @@ export function useWorkflowExecution() { if (activeWorkflowId) { logger.info('Using server-side executor') + const executionId = uuidv4() + let executionResult: ExecutionResult = { success: false, output: {}, @@ -921,6 +924,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) => { @@ -955,24 +979,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) { @@ -1007,25 +1030,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) => { @@ -1151,7 +1173,7 @@ export function useWorkflowExecution() { endedAt: new Date().toISOString(), workflowId: activeWorkflowId, blockId: 'validation', - executionId: executionId || uuidv4(), + executionId, blockName: 'Workflow Validation', blockType: 'validation', }) @@ -1420,6 +1442,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) @@ -1436,6 +1463,8 @@ export function useWorkflowExecution() { setIsExecuting, setIsDebugging, setActiveBlocks, + activeWorkflowId, + cancelRunningEntries, ]) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index b6a01a646..8e4839ef9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -1141,15 +1141,17 @@ function PreviewEditorContent({
{/* Header - styled like editor */}
-
- -
+ {block.type !== 'note' && ( +
+ +
+ )} {block.name || blockConfig.name} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index ca0c3ed5d..a484f15ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -411,8 +411,9 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const IconComponent = blockConfig.icon const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger + const isNoteBlock = type === 'note' - const shouldShowDefaultHandles = !isStarterOrTrigger + const shouldShowDefaultHandles = !isStarterOrTrigger && !isNoteBlock const hasSubBlocks = visibleSubBlocks.length > 0 const hasContentBelowHeader = type === 'condition' @@ -574,8 +575,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps )} - {/* Source and error handles for non-condition/router blocks */} - {type !== 'condition' && type !== 'router_v2' && type !== 'response' && ( + {/* Source and error handles for non-condition/router/note blocks */} + {type !== 'condition' && type !== 'router_v2' && type !== 'response' && !isNoteBlock && ( <> @@ -91,12 +84,7 @@ function calculateContainerDimensions( return { width, height } } -/** - * Finds the leftmost block ID from a workflow state. - * Excludes subflow containers (loop/parallel) from consideration. - * @param workflowState - The workflow state to search - * @returns The ID of the leftmost block, or null if no blocks exist - */ +/** Finds the leftmost block ID, excluding subflow containers. */ export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null { if (!workflowState?.blocks) return null @@ -118,7 +106,7 @@ export function getLeftmostBlockId(workflowState: WorkflowState | null | undefin /** Execution status for edges/nodes in the preview */ type ExecutionStatus = 'success' | 'error' | 'not-executed' -/** Calculates absolute position for blocks, handling nested subflows */ +/** Calculates absolute position, handling nested subflows. */ function calculateAbsolutePosition( block: BlockState, blocks: Record @@ -164,10 +152,7 @@ interface PreviewWorkflowProps { lightweight?: boolean } -/** - * Preview node types using minimal components without hooks or store subscriptions. - * This prevents interaction issues while allowing canvas panning and node clicking. - */ +/** Preview node types using minimal, hook-free components. */ const previewNodeTypes: NodeTypes = { workflowBlock: PreviewBlock, noteBlock: PreviewBlock, @@ -185,11 +170,7 @@ interface FitViewOnChangeProps { containerRef: React.RefObject } -/** - * Helper component that calls fitView when the set of nodes changes or when the container resizes. - * Only triggers on actual node additions/removals, not on selection changes. - * Must be rendered inside ReactFlowProvider. - */ +/** Calls fitView on node changes or container resize. */ function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) { const { fitView } = useReactFlow() const lastNodeIdsRef = useRef(null) @@ -229,16 +210,7 @@ function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeP return null } -/** - * Readonly workflow component for visualizing workflow state. - * Renders blocks, subflows, and edges with execution status highlighting. - * - * @remarks - * - Supports panning and node click interactions - * - Shows execution path via green edges for successful paths - * - Error edges display red by default, green when error path was taken - * - Fits view automatically when nodes change or container resizes - */ +/** Readonly workflow visualization with execution status highlighting. */ export function PreviewWorkflow({ workflowState, className, @@ -300,49 +272,58 @@ export function PreviewWorkflow({ return map }, [workflowState.blocks, isValidWorkflowState]) - /** Derives subflow execution status from child blocks */ + /** Maps base block IDs to execution data, handling parallel iteration variants (blockId₍n₎). */ + const blockExecutionMap = useMemo(() => { + if (!executedBlocks) return new Map() + + const map = new Map() + for (const [key, value] of Object.entries(executedBlocks)) { + // Extract base ID (remove iteration suffix like ₍0₎) + const baseId = key.includes('₍') ? key.split('₍')[0] : key + // Keep first match or error status (error takes precedence) + const existing = map.get(baseId) + if (!existing || value.status === 'error') { + map.set(baseId, value) + } + } + return map + }, [executedBlocks]) + + /** Derives subflow status from children. Error takes precedence. */ const getSubflowExecutionStatus = useMemo(() => { return (subflowId: string): ExecutionStatus | undefined => { - if (!executedBlocks) return undefined - const childIds = subflowChildrenMap.get(subflowId) if (!childIds?.length) return undefined - const childStatuses = childIds.map((id) => executedBlocks[id]).filter(Boolean) - if (childStatuses.length === 0) return undefined + const executedChildren = childIds + .map((id) => blockExecutionMap.get(id)) + .filter((status): status is { status: string } => Boolean(status)) - if (childStatuses.some((s) => s.status === 'error')) return 'error' - if (childStatuses.some((s) => s.status === 'success')) return 'success' - return 'not-executed' + if (executedChildren.length === 0) return undefined + if (executedChildren.some((s) => s.status === 'error')) return 'error' + return 'success' } - }, [executedBlocks, subflowChildrenMap]) + }, [subflowChildrenMap, blockExecutionMap]) - /** Gets execution status for any block, deriving subflow status from children */ + /** Gets block status. Subflows derive status from children. */ const getBlockExecutionStatus = useMemo(() => { return (blockId: string): { status: string; executed: boolean } | undefined => { - if (!executedBlocks) return undefined - - const directStatus = executedBlocks[blockId] + const directStatus = blockExecutionMap.get(blockId) if (directStatus) { return { status: directStatus.status, executed: true } } const block = workflowState.blocks?.[blockId] - if (block && (block.type === 'loop' || block.type === 'parallel')) { + if (block?.type === 'loop' || block?.type === 'parallel') { const subflowStatus = getSubflowExecutionStatus(blockId) if (subflowStatus) { return { status: subflowStatus, executed: true } } - - const incomingEdge = workflowState.edges?.find((e) => e.target === blockId) - if (incomingEdge && executedBlocks[incomingEdge.source]?.status === 'success') { - return { status: 'not-executed', executed: true } - } } return undefined } - }, [executedBlocks, workflowState.blocks, workflowState.edges, getSubflowExecutionStatus]) + }, [workflowState.blocks, getSubflowExecutionStatus, blockExecutionMap]) const edgesStructure = useMemo(() => { if (!isValidWorkflowState) return { count: 0, ids: '' } @@ -406,9 +387,11 @@ export function PreviewWorkflow({ } } + const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock' + nodeArray.push({ id: blockId, - type: 'workflowBlock', + type: nodeType, position: absolutePosition, draggable: false, zIndex: block.data?.parentId ? 10 : undefined, @@ -442,48 +425,29 @@ export function PreviewWorkflow({ const edges: Edge[] = useMemo(() => { if (!isValidWorkflowState) return [] - /** - * Determines edge execution status for visualization. - * Error edges turn green when taken (source errored, target executed). - * Normal edges turn green when both source succeeded and target executed. - */ + /** Edge is green if target executed and source condition met by edge type. */ const getEdgeExecutionStatus = (edge: { source: string target: string sourceHandle?: string | null }): ExecutionStatus | undefined => { - if (!executedBlocks) return undefined + if (blockExecutionMap.size === 0) return undefined + + const targetStatus = getBlockExecutionStatus(edge.target) + if (!targetStatus?.executed) return 'not-executed' const sourceStatus = getBlockExecutionStatus(edge.source) - const targetStatus = getBlockExecutionStatus(edge.target) - const isErrorEdge = edge.sourceHandle === 'error' + const { sourceHandle } = edge - if (isErrorEdge) { - return sourceStatus?.status === 'error' && targetStatus?.executed - ? 'success' - : 'not-executed' + if (sourceHandle === 'error') { + return sourceStatus?.status === 'error' ? 'success' : 'not-executed' } - const isSubflowStartEdge = - edge.sourceHandle === 'loop-start-source' || edge.sourceHandle === 'parallel-start-source' - - if (isSubflowStartEdge) { - const incomingEdge = workflowState.edges?.find((e) => e.target === edge.source) - const incomingSucceeded = incomingEdge - ? executedBlocks[incomingEdge.source]?.status === 'success' - : false - return incomingSucceeded ? 'success' : 'not-executed' - } - - const targetBlock = workflowState.blocks?.[edge.target] - const targetIsSubflow = - targetBlock && (targetBlock.type === 'loop' || targetBlock.type === 'parallel') - - if (sourceStatus?.status === 'success' && (targetStatus?.executed || targetIsSubflow)) { + if (sourceHandle === 'loop-start-source' || sourceHandle === 'parallel-start-source') { return 'success' } - return 'not-executed' + return sourceStatus?.status === 'success' ? 'success' : 'not-executed' } return (workflowState.edges || []).map((edge) => { @@ -505,9 +469,8 @@ export function PreviewWorkflow({ }, [ edgesStructure, workflowState.edges, - workflowState.blocks, isValidWorkflowState, - executedBlocks, + blockExecutionMap, getBlockExecutionStatus, ]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx index 660389c24..daa4817b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx @@ -164,7 +164,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr ...prev, { email: normalized, - permissionType: 'read', + permissionType: 'admin', }, ]) } diff --git a/apps/sim/components/emcn/components/code/code.css b/apps/sim/components/emcn/components/code/code.css index 11083892e..be90a337a 100644 --- a/apps/sim/components/emcn/components/code/code.css +++ b/apps/sim/components/emcn/components/code/code.css @@ -37,7 +37,7 @@ .code-editor-theme .token.char, .code-editor-theme .token.builtin, .code-editor-theme .token.inserted { - color: #dc2626 !important; + color: #b45309 !important; } .code-editor-theme .token.operator, @@ -49,7 +49,7 @@ .code-editor-theme .token.atrule, .code-editor-theme .token.attr-value, .code-editor-theme .token.keyword { - color: #2563eb !important; + color: #2f55ff !important; } .code-editor-theme .token.function, @@ -119,7 +119,7 @@ .dark .code-editor-theme .token.atrule, .dark .code-editor-theme .token.attr-value, .dark .code-editor-theme .token.keyword { - color: #4db8ff !important; + color: #2fa1ff !important; } .dark .code-editor-theme .token.function, diff --git a/apps/sim/components/emcn/components/code/code.tsx b/apps/sim/components/emcn/components/code/code.tsx index ee03b04fa..58250adc1 100644 --- a/apps/sim/components/emcn/components/code/code.tsx +++ b/apps/sim/components/emcn/components/code/code.tsx @@ -10,6 +10,7 @@ import { useRef, useState, } from 'react' +import { ChevronRight } from 'lucide-react' import { highlight, languages } from 'prismjs' import { List, type RowComponentProps, useDynamicRowHeight, useListRef } from 'react-window' import 'prismjs/components/prism-javascript' @@ -36,6 +37,11 @@ export const CODE_LINE_HEIGHT_PX = 21 */ const GUTTER_WIDTHS = [20, 20, 30, 38, 46, 54] as const +/** + * Width of the collapse column in pixels. + */ +const COLLAPSE_COLUMN_WIDTH = 12 + /** * Calculates the dynamic gutter width based on the number of lines. * @param lineCount - The total number of lines in the code @@ -46,6 +52,296 @@ export function calculateGutterWidth(lineCount: number): number { return GUTTER_WIDTHS[Math.min(digits - 1, GUTTER_WIDTHS.length - 1)] } +/** + * Information about a collapsible region in code. + */ +interface CollapsibleRegion { + /** Line index where the region starts (0-based) */ + startLine: number + /** Line index where the region ends (0-based, inclusive) */ + endLine: number + /** Type of collapsible region */ + type: 'block' | 'string' +} + +/** + * Minimum string length to be considered collapsible. + */ +const MIN_COLLAPSIBLE_STRING_LENGTH = 80 + +/** + * Maximum length of truncated string preview when collapsed. + */ +const MAX_TRUNCATED_STRING_LENGTH = 30 + +/** + * Regex to match a JSON string value (key: "value" pattern). + * Pre-compiled for performance. + */ +const STRING_VALUE_REGEX = /:\s*"([^"\\]|\\.)*"[,]?\s*$/ + +/** + * Finds collapsible regions in JSON code by matching braces and detecting long strings. + * A region is collapsible if it spans multiple lines OR contains a long string value. + * Properly handles braces inside JSON strings by tracking string boundaries with correct + * escape sequence handling (counts consecutive backslashes to determine if quotes are escaped). + * + * @param lines - Array of code lines + * @returns Map of start line index to CollapsibleRegion + */ +function findCollapsibleRegions(lines: string[]): Map { + const regions = new Map() + const stringRegions = new Map() + const stack: { char: '{' | '['; line: number }[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Detect collapsible string values (long strings on a single line) + const stringMatch = line.match(STRING_VALUE_REGEX) + if (stringMatch) { + const colonIdx = line.indexOf('":') + if (colonIdx !== -1) { + const valueStart = line.indexOf('"', colonIdx + 1) + const valueEnd = line.lastIndexOf('"') + if (valueStart !== -1 && valueEnd > valueStart) { + const stringValue = line.slice(valueStart + 1, valueEnd) + if (stringValue.length >= MIN_COLLAPSIBLE_STRING_LENGTH || stringValue.includes('\\n')) { + // Store separately to avoid conflicts with block regions + stringRegions.set(i, { startLine: i, endLine: i, type: 'string' }) + } + } + } + } + + // Check for block regions, skipping characters inside strings + let inString = false + for (let j = 0; j < line.length; j++) { + const char = line[j] + + // Toggle string state on unescaped quotes + // Must count consecutive backslashes: odd = escaped quote, even = unescaped quote + if (char === '"') { + let backslashCount = 0 + let k = j - 1 + while (k >= 0 && line[k] === '\\') { + backslashCount++ + k-- + } + // Only toggle if quote is not escaped (even number of preceding backslashes) + if (backslashCount % 2 === 0) { + inString = !inString + } + continue + } + + // Skip braces inside strings + if (inString) continue + + if (char === '{' || char === '[') { + stack.push({ char, line: i }) + } else if (char === '}' || char === ']') { + const expected = char === '}' ? '{' : '[' + if (stack.length > 0 && stack[stack.length - 1].char === expected) { + const start = stack.pop()! + if (i > start.line) { + regions.set(start.line, { + startLine: start.line, + endLine: i, + type: 'block', + }) + } + } + } + } + } + + // Merge string regions only where no block region exists (block takes priority) + for (const [lineIdx, region] of stringRegions) { + if (!regions.has(lineIdx)) { + regions.set(lineIdx, region) + } + } + + return regions +} + +/** + * Computes visible line indices based on collapsed regions. + * Only block regions hide lines; string regions just truncate content. + * + * @param totalLines - Total number of lines + * @param collapsedLines - Set of line indices that are collapsed (start lines of regions) + * @param regions - Map of collapsible regions + * @returns Sorted array of visible line indices + */ +function computeVisibleLineIndices( + totalLines: number, + collapsedLines: Set, + regions: Map +): number[] { + if (collapsedLines.size === 0) { + return Array.from({ length: totalLines }, (_, i) => i) + } + + // Build sorted list of hidden ranges (only for block regions, not string regions) + const hiddenRanges: Array<{ start: number; end: number }> = [] + for (const startLine of collapsedLines) { + const region = regions.get(startLine) + if (region && region.type === 'block' && region.endLine > region.startLine + 1) { + hiddenRanges.push({ start: region.startLine + 1, end: region.endLine - 1 }) + } + } + hiddenRanges.sort((a, b) => a.start - b.start) + + // Merge overlapping ranges + const merged: Array<{ start: number; end: number }> = [] + for (const range of hiddenRanges) { + if (merged.length === 0 || merged[merged.length - 1].end < range.start - 1) { + merged.push(range) + } else { + merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, range.end) + } + } + + // Build visible indices by skipping hidden ranges + const visible: number[] = [] + let rangeIdx = 0 + for (let i = 0; i < totalLines; i++) { + while (rangeIdx < merged.length && merged[rangeIdx].end < i) { + rangeIdx++ + } + if (rangeIdx < merged.length && i >= merged[rangeIdx].start && i <= merged[rangeIdx].end) { + continue + } + visible.push(i) + } + + return visible +} + +/** + * Truncates a long string value in a JSON line for collapsed display. + * + * @param line - The original line content + * @returns Truncated line with ellipsis + */ +function truncateStringLine(line: string): string { + const colonIdx = line.indexOf('":') + if (colonIdx === -1) return line + + const valueStart = line.indexOf('"', colonIdx + 1) + if (valueStart === -1) return line + + const prefix = line.slice(0, valueStart + 1) + const suffix = line.charCodeAt(line.length - 1) === 44 /* ',' */ ? '",' : '"' + const truncated = line.slice(valueStart + 1, valueStart + 1 + MAX_TRUNCATED_STRING_LENGTH) + + return `${prefix}${truncated}...${suffix}` +} + +/** + * Custom hook for managing JSON collapse state and computations. + * + * @param lines - Array of code lines + * @param showCollapseColumn - Whether collapse functionality is enabled + * @param language - Programming language for syntax detection + * @returns Object containing collapse state and handlers + */ +function useJsonCollapse( + lines: string[], + showCollapseColumn: boolean, + language: string +): { + collapsedLines: Set + collapsibleLines: Set + collapsibleRegions: Map + collapsedStringLines: Set + visibleLineIndices: number[] + toggleCollapse: (lineIndex: number) => void +} { + const [collapsedLines, setCollapsedLines] = useState>(new Set()) + + const collapsibleRegions = useMemo(() => { + if (!showCollapseColumn || language !== 'json') return new Map() + return findCollapsibleRegions(lines) + }, [lines, showCollapseColumn, language]) + + const collapsibleLines = useMemo(() => new Set(collapsibleRegions.keys()), [collapsibleRegions]) + + // Track which collapsed lines are string type (need truncation, not hiding) + const collapsedStringLines = useMemo(() => { + const stringLines = new Set() + for (const lineIdx of collapsedLines) { + const region = collapsibleRegions.get(lineIdx) + if (region?.type === 'string') { + stringLines.add(lineIdx) + } + } + return stringLines + }, [collapsedLines, collapsibleRegions]) + + const visibleLineIndices = useMemo(() => { + if (!showCollapseColumn) { + return Array.from({ length: lines.length }, (_, i) => i) + } + return computeVisibleLineIndices(lines.length, collapsedLines, collapsibleRegions) + }, [lines.length, collapsedLines, collapsibleRegions, showCollapseColumn]) + + const toggleCollapse = useCallback((lineIndex: number) => { + setCollapsedLines((prev) => { + const next = new Set(prev) + if (next.has(lineIndex)) { + next.delete(lineIndex) + } else { + next.add(lineIndex) + } + return next + }) + }, []) + + return { + collapsedLines, + collapsibleLines, + collapsibleRegions, + collapsedStringLines, + visibleLineIndices, + toggleCollapse, + } +} + +/** + * Props for the CollapseButton component. + */ +interface CollapseButtonProps { + /** Whether the region is currently collapsed */ + isCollapsed: boolean + /** Handler for toggle click */ + onClick: () => void +} + +/** + * Collapse/expand button with chevron icon. + * Rotates chevron based on collapse state. + */ +const CollapseButton = memo(function CollapseButton({ isCollapsed, onClick }: CollapseButtonProps) { + return ( + + ) +}) + /** * Props for the Code.Container component. */ @@ -256,28 +552,62 @@ function Placeholder({ children, gutterWidth, show, className }: CodePlaceholder } /** - * Props for virtualized row rendering. + * Represents a highlighted line of code. */ interface HighlightedLine { + /** 1-based line number */ lineNumber: number + /** Syntax-highlighted HTML content */ html: string } +/** + * Props for virtualized row rendering. + */ interface CodeRowProps { + /** Array of highlighted lines to render */ lines: HighlightedLine[] + /** Width of the gutter in pixels */ gutterWidth: number + /** Whether to show the line number gutter */ showGutter: boolean + /** Custom styles for the gutter */ gutterStyle?: React.CSSProperties + /** Left offset for alignment */ leftOffset: number + /** Whether to wrap long lines */ wrapText: boolean + /** Whether to show the collapse column */ + showCollapseColumn: boolean + /** Set of line indices that can be collapsed */ + collapsibleLines: Set + /** Set of line indices that are currently collapsed */ + collapsedLines: Set + /** Handler for toggling collapse state */ + onToggleCollapse: (lineIndex: number) => void } /** * Row component for virtualized code viewer. + * Renders a single line with optional gutter and collapse button. */ function CodeRow({ index, style, ...props }: RowComponentProps) { - const { lines, gutterWidth, showGutter, gutterStyle, leftOffset, wrapText } = props + const { + lines, + gutterWidth, + showGutter, + gutterStyle, + leftOffset, + wrapText, + showCollapseColumn, + collapsibleLines, + collapsedLines, + onToggleCollapse, + } = props const line = lines[index] + const originalLineIndex = line.lineNumber - 1 + const isCollapsible = showCollapseColumn && collapsibleLines.has(originalLineIndex) + const isCollapsed = collapsedLines.has(originalLineIndex) return (
@@ -289,6 +619,19 @@ function CodeRow({ index, style, ...props }: RowComponentProps) { {line.lineNumber}
)} + {showCollapseColumn && ( +
+ {isCollapsible && ( + onToggleCollapse(originalLineIndex)} + /> + )} +
+ )}
) {
 
 /**
  * Applies search highlighting to a single line for virtualized rendering.
+ *
+ * @param html - The syntax-highlighted HTML string
+ * @param searchQuery - The search query to highlight
+ * @param currentMatchIndex - Index of the current match (for distinct highlighting)
+ * @param globalMatchOffset - Cumulative match count before this line
+ * @returns Object containing highlighted HTML and count of matches in this line
  */
 function applySearchHighlightingToLine(
   html: string,
@@ -366,6 +715,8 @@ interface CodeViewerProps {
   contentRef?: React.RefObject
   /** Enable virtualized rendering for large outputs (uses react-window) */
   virtualized?: boolean
+  /** Whether to show a collapse column for JSON folding (only for json language) */
+  showCollapseColumn?: boolean
 }
 
 /**
@@ -422,42 +773,39 @@ function applySearchHighlighting(
     .join('')
 }
 
-/**
- * Counts all matches for a search query in the given code.
- *
- * @param code - The raw code string
- * @param searchQuery - The search query
- * @returns Number of matches found
- */
-function countSearchMatches(code: string, searchQuery: string): number {
-  if (!searchQuery.trim()) return 0
-
-  const escaped = escapeRegex(searchQuery)
-  const regex = new RegExp(escaped, 'gi')
-  const matches = code.match(regex)
-
-  return matches?.length ?? 0
-}
-
 /**
  * Props for inner viewer components (with defaults already applied).
  */
 type ViewerInnerProps = {
+  /** Code content to display */
   code: string
+  /** Whether to show line numbers gutter */
   showGutter: boolean
+  /** Language for syntax highlighting */
   language: 'javascript' | 'json' | 'python'
+  /** Additional CSS classes for the container */
   className?: string
+  /** Left padding offset in pixels */
   paddingLeft: number
+  /** Custom styles for the gutter */
   gutterStyle?: React.CSSProperties
+  /** Whether to wrap long lines */
   wrapText: boolean
+  /** Search query to highlight */
   searchQuery?: string
+  /** Index of the current active match */
   currentMatchIndex: number
+  /** Callback when match count changes */
   onMatchCountChange?: (count: number) => void
+  /** Ref for the content container */
   contentRef?: React.RefObject
+  /** Whether to show collapse column for JSON folding */
+  showCollapseColumn: boolean
 }
 
 /**
  * Virtualized code viewer implementation using react-window.
+ * Optimized for large outputs with efficient scrolling and dynamic row heights.
  */
 const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
   code,
@@ -471,6 +819,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
   currentMatchIndex,
   onMatchCountChange,
   contentRef,
+  showCollapseColumn,
 }: ViewerInnerProps) {
   const containerRef = useRef(null)
   const listRef = useListRef(null)
@@ -481,52 +830,70 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
     key: wrapText ? 'wrap' : 'nowrap',
   })
 
-  const matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
+  const lines = useMemo(() => code.split('\n'), [code])
+  const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
+
+  const {
+    collapsedLines,
+    collapsibleLines,
+    collapsedStringLines,
+    visibleLineIndices,
+    toggleCollapse,
+  } = useJsonCollapse(lines, showCollapseColumn, language)
+
+  // Compute display lines (accounting for truncation of collapsed strings)
+  const displayLines = useMemo(() => {
+    return lines.map((line, idx) =>
+      collapsedStringLines.has(idx) ? truncateStringLine(line) : line
+    )
+  }, [lines, collapsedStringLines])
+
+  // Pre-compute cumulative match offsets based on DISPLAYED content (handles truncation)
+  const { matchOffsets, matchCount } = useMemo(() => {
+    if (!searchQuery?.trim()) return { matchOffsets: [], matchCount: 0 }
+
+    const offsets: number[] = []
+    let cumulative = 0
+    const escaped = escapeRegex(searchQuery)
+    const regex = new RegExp(escaped, 'gi')
+    const visibleSet = new Set(visibleLineIndices)
+
+    for (let i = 0; i < lines.length; i++) {
+      offsets.push(cumulative)
+      // Only count matches in visible lines, using displayed (possibly truncated) content
+      if (visibleSet.has(i)) {
+        const matches = displayLines[i].match(regex)
+        cumulative += matches?.length ?? 0
+      }
+    }
+    return { matchOffsets: offsets, matchCount: cumulative }
+  }, [lines.length, displayLines, visibleLineIndices, searchQuery])
 
   useEffect(() => {
     onMatchCountChange?.(matchCount)
   }, [matchCount, onMatchCountChange])
 
-  const lines = useMemo(() => code.split('\n'), [code])
-  const lineCount = lines.length
-  const gutterWidth = useMemo(() => calculateGutterWidth(lineCount), [lineCount])
-
-  const highlightedLines = useMemo(() => {
+  // Only process visible lines for efficiency (not all lines)
+  const visibleLines = useMemo(() => {
     const lang = languages[language] || languages.javascript
-    return lines.map((line, idx) => ({
-      lineNumber: idx + 1,
-      html: highlight(line, lang, language),
-    }))
-  }, [lines, language])
+    const hasSearch = searchQuery?.trim()
 
-  const matchOffsets = useMemo(() => {
-    if (!searchQuery?.trim()) return []
-    const offsets: number[] = []
-    let cumulative = 0
-    const escaped = escapeRegex(searchQuery)
-    const regex = new RegExp(escaped, 'gi')
+    return visibleLineIndices.map((idx) => {
+      let html = highlight(displayLines[idx], lang, language)
 
-    for (const line of lines) {
-      offsets.push(cumulative)
-      const matches = line.match(regex)
-      cumulative += matches?.length ?? 0
-    }
-    return offsets
-  }, [lines, searchQuery])
+      if (hasSearch && searchQuery) {
+        const result = applySearchHighlightingToLine(
+          html,
+          searchQuery,
+          currentMatchIndex,
+          matchOffsets[idx]
+        )
+        html = result.html
+      }
 
-  const linesWithSearch = useMemo(() => {
-    if (!searchQuery?.trim()) return highlightedLines
-
-    return highlightedLines.map((line, idx) => {
-      const { html } = applySearchHighlightingToLine(
-        line.html,
-        searchQuery,
-        currentMatchIndex,
-        matchOffsets[idx]
-      )
-      return { ...line, html }
+      return { lineNumber: idx + 1, html }
     })
-  }, [highlightedLines, searchQuery, currentMatchIndex, matchOffsets])
+  }, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex, matchOffsets])
 
   useEffect(() => {
     if (!searchQuery?.trim() || matchCount === 0 || !listRef.current) return
@@ -535,12 +902,15 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
     for (let i = 0; i < matchOffsets.length; i++) {
       const matchesInThisLine = (matchOffsets[i + 1] ?? matchCount) - matchOffsets[i]
       if (currentMatchIndex >= accumulated && currentMatchIndex < accumulated + matchesInThisLine) {
-        listRef.current.scrollToRow({ index: i, align: 'center' })
+        const visibleIndex = visibleLineIndices.indexOf(i)
+        if (visibleIndex !== -1) {
+          listRef.current.scrollToRow({ index: visibleIndex, align: 'center' })
+        }
         break
       }
       accumulated += matchesInThisLine
     }
-  }, [currentMatchIndex, searchQuery, matchCount, matchOffsets, listRef])
+  }, [currentMatchIndex, searchQuery, matchCount, matchOffsets, listRef, visibleLineIndices])
 
   useEffect(() => {
     const container = containerRef.current
@@ -549,15 +919,11 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
     const parent = container.parentElement
     if (!parent) return
 
-    const updateHeight = () => {
-      setContainerHeight(parent.clientHeight)
-    }
-
+    const updateHeight = () => setContainerHeight(parent.clientHeight)
     updateHeight()
 
     const resizeObserver = new ResizeObserver(updateHeight)
     resizeObserver.observe(parent)
-
     return () => resizeObserver.disconnect()
   }, [])
 
@@ -571,7 +937,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
     if (rows.length === 0) return
 
     return dynamicRowHeight.observeRowElements(rows)
-  }, [wrapText, dynamicRowHeight, linesWithSearch])
+  }, [wrapText, dynamicRowHeight, visibleLines])
 
   const setRefs = useCallback(
     (el: HTMLDivElement | null) => {
@@ -583,16 +949,34 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
     [contentRef]
   )
 
+  const hasCollapsibleContent = collapsibleLines.size > 0
+  const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent
+
   const rowProps = useMemo(
     () => ({
-      lines: linesWithSearch,
+      lines: visibleLines,
       gutterWidth,
       showGutter,
       gutterStyle,
       leftOffset: paddingLeft,
       wrapText,
+      showCollapseColumn: effectiveShowCollapseColumn,
+      collapsibleLines,
+      collapsedLines,
+      onToggleCollapse: toggleCollapse,
     }),
-    [linesWithSearch, gutterWidth, showGutter, gutterStyle, paddingLeft, wrapText]
+    [
+      visibleLines,
+      gutterWidth,
+      showGutter,
+      gutterStyle,
+      paddingLeft,
+      wrapText,
+      effectiveShowCollapseColumn,
+      collapsibleLines,
+      collapsedLines,
+      toggleCollapse,
+    ]
   )
 
   return (
@@ -609,7 +993,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
        code.split('\n'), [code])
+  const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
+
+  const {
+    collapsedLines,
+    collapsibleLines,
+    collapsedStringLines,
+    visibleLineIndices,
+    toggleCollapse,
+  } = useJsonCollapse(lines, showCollapseColumn, language)
+
+  // Compute display lines (accounting for truncation of collapsed strings)
+  const displayLines = useMemo(() => {
+    return lines.map((line, idx) =>
+      collapsedStringLines.has(idx) ? truncateStringLine(line) : line
+    )
+  }, [lines, collapsedStringLines])
+
+  // Pre-compute cumulative match offsets based on DISPLAYED content (handles truncation)
+  const { cumulativeMatches, matchCount } = useMemo(() => {
+    if (!searchQuery?.trim()) return { cumulativeMatches: [0], matchCount: 0 }
+
+    const cumulative: number[] = [0]
+    const escaped = escapeRegex(searchQuery)
+    const regex = new RegExp(escaped, 'gi')
+    const visibleSet = new Set(visibleLineIndices)
+
+    for (let i = 0; i < lines.length; i++) {
+      const prev = cumulative[cumulative.length - 1]
+      // Only count matches in visible lines, using displayed content
+      if (visibleSet.has(i)) {
+        const matches = displayLines[i].match(regex)
+        cumulative.push(prev + (matches?.length ?? 0))
+      } else {
+        cumulative.push(prev)
+      }
+    }
+    return { cumulativeMatches: cumulative, matchCount: cumulative[cumulative.length - 1] }
+  }, [lines.length, displayLines, visibleLineIndices, searchQuery])
+
+  useEffect(() => {
+    onMatchCountChange?.(matchCount)
+  }, [matchCount, onMatchCountChange])
+
+  // Pre-compute highlighted lines with search for visible indices (for gutter mode)
+  const highlightedVisibleLines = useMemo(() => {
+    const lang = languages[language] || languages.javascript
+
+    if (!searchQuery?.trim()) {
+      return visibleLineIndices.map((idx) => ({
+        lineNumber: idx + 1,
+        html: highlight(displayLines[idx], lang, language) || ' ',
+      }))
+    }
+
+    return visibleLineIndices.map((idx) => {
+      let html = highlight(displayLines[idx], lang, language)
+      const matchCounter = { count: cumulativeMatches[idx] }
+      html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
+      return { lineNumber: idx + 1, html: html || ' ' }
+    })
+  }, [
+    displayLines,
+    language,
+    visibleLineIndices,
+    searchQuery,
+    currentMatchIndex,
+    cumulativeMatches,
+  ])
+
+  // Pre-compute simple highlighted code (for no-gutter mode)
+  const highlightedCode = useMemo(() => {
+    const lang = languages[language] || languages.javascript
+    const visibleCode = visibleLineIndices.map((idx) => displayLines[idx]).join('\n')
+    let html = highlight(visibleCode, lang, language)
+
+    if (searchQuery?.trim()) {
+      const matchCounter = { count: 0 }
+      html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
+    }
+    return html
+  }, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex])
+
+  const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
+
+  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) {
+    return (
+      
+        
+          
+ {highlightedVisibleLines.map(({ lineNumber, html }) => { + const idx = lineNumber - 1 + const isCollapsible = collapsibleLines.has(idx) + const isCollapsed = collapsedLines.has(idx) + + return ( + +
+ {lineNumber} +
+ {effectiveShowCollapseColumn && ( +
+ {isCollapsible && ( + toggleCollapse(idx)} + /> + )} +
+ )} +
+                
+              )
+            })}
+          
+
+
+ ) + } + + // Simple display without gutter + return ( + + +
 0 ? paddingLeft : undefined }}
+          dangerouslySetInnerHTML={{ __html: highlightedCode }}
+        />
+      
+    
+  )
+}
+
 /**
  * Readonly code viewer with optional gutter and syntax highlighting.
- * Handles all complexity internally - line numbers, gutter width calculation, and highlighting.
- * Supports optional search highlighting with navigation.
+ * Routes to either standard or virtualized implementation based on the `virtualized` prop.
  *
  * @example
  * ```tsx
@@ -645,162 +1207,6 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
  * />
  * ```
  */
-/**
- * Non-virtualized code viewer implementation.
- */
-function ViewerInner({
-  code,
-  showGutter,
-  language,
-  className,
-  paddingLeft,
-  gutterStyle,
-  wrapText,
-  searchQuery,
-  currentMatchIndex,
-  onMatchCountChange,
-  contentRef,
-}: ViewerInnerProps) {
-  // Compute match count and notify parent
-  const matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
-
-  useEffect(() => {
-    onMatchCountChange?.(matchCount)
-  }, [matchCount, onMatchCountChange])
-
-  // Determine whitespace class based on wrap setting
-  const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
-
-  // Special rendering path: when wrapping with gutter, render per-line rows so gutter stays aligned.
-  if (showGutter && wrapText) {
-    const lines = code.split('\n')
-    const gutterWidth = calculateGutterWidth(lines.length)
-    const matchCounter = { count: 0 }
-
-    return (
-      
-        
-          
- {lines.map((line, idx) => { - let perLineHighlighted = highlight( - line, - languages[language] || languages.javascript, - language - ) - - // Apply search highlighting if query exists - if (searchQuery?.trim()) { - perLineHighlighted = applySearchHighlighting( - perLineHighlighted, - searchQuery, - currentMatchIndex, - matchCounter - ) - } - - return ( - -
- {idx + 1} -
-
-                
-              )
-            })}
-          
-
-
- ) - } - - // Apply syntax highlighting - let highlightedCode = highlight(code, languages[language] || languages.javascript, language) - - // Apply search highlighting if query exists - if (searchQuery?.trim()) { - const matchCounter = { count: 0 } - highlightedCode = applySearchHighlighting( - highlightedCode, - searchQuery, - currentMatchIndex, - matchCounter - ) - } - - if (!showGutter) { - // Simple display without gutter - return ( - - -
-        
-      
-    )
-  }
-
-  // Calculate line numbers
-  const lineCount = code.split('\n').length
-  const gutterWidth = calculateGutterWidth(lineCount)
-
-  // Render line numbers
-  const lineNumbers = []
-  for (let i = 1; i <= lineCount; i++) {
-    lineNumbers.push(
-      
- {i} -
- ) - } - - return ( - - - {lineNumbers} - - -
-      
-    
-  )
-}
-
-/**
- * Readonly code viewer with optional gutter and syntax highlighting.
- * Routes to either standard or virtualized implementation based on the `virtualized` prop.
- */
 function Viewer({
   code,
   showGutter = false,
@@ -814,6 +1220,7 @@ function Viewer({
   onMatchCountChange,
   contentRef,
   virtualized = false,
+  showCollapseColumn = false,
 }: CodeViewerProps) {
   const innerProps: ViewerInnerProps = {
     code,
@@ -827,6 +1234,7 @@ function Viewer({
     currentMatchIndex,
     onMatchCountChange,
     contentRef,
+    showCollapseColumn,
   }
 
   return virtualized ?  : 
diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts
index d2eb2bfd3..d22db32c9 100644
--- a/apps/sim/stores/constants.ts
+++ b/apps/sim/stores/constants.ts
@@ -36,7 +36,7 @@ export const PANEL_WIDTH = {
 
 /** Terminal height constraints */
 export const TERMINAL_HEIGHT = {
-  DEFAULT: 155,
+  DEFAULT: 206,
   MIN: 30,
   /** Maximum is 70% of viewport, enforced dynamically */
   MAX_PERCENTAGE: 0.7,
@@ -58,6 +58,9 @@ export const EDITOR_CONNECTIONS_HEIGHT = {
 
 /** Output panel (terminal execution results) width constraints */
 export const OUTPUT_PANEL_WIDTH = {
-  DEFAULT: 440,
-  MIN: 440,
+  DEFAULT: 560,
+  MIN: 280,
 } as const
+
+/** Terminal block column width - minimum width for the logs column */
+export const TERMINAL_BLOCK_COLUMN_WIDTH = 240 as const
diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts
index 0f747f82c..15298c625 100644
--- a/apps/sim/stores/terminal/console/store.ts
+++ b/apps/sim/stores/terminal/console/store.ts
@@ -339,12 +339,49 @@ export const useTerminalConsoleStore = create()(
                     : 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
 }
diff --git a/apps/sim/stores/terminal/store.ts b/apps/sim/stores/terminal/store.ts
index faf42d25d..6d8ea91c9 100644
--- a/apps/sim/stores/terminal/store.ts
+++ b/apps/sim/stores/terminal/store.ts
@@ -69,6 +69,15 @@ export const useTerminalStore = create()(
       setWrapText: (wrap) => {
         set({ wrapText: wrap })
       },
+      structuredView: true,
+      /**
+       * Enables or disables structured view mode in the output panel.
+       *
+       * @param structured - Whether output should be displayed as nested blocks.
+       */
+      setStructuredView: (structured) => {
+        set({ structuredView: structured })
+      },
       /**
        * Indicates whether the terminal store has finished client-side hydration.
        */
diff --git a/apps/sim/stores/terminal/types.ts b/apps/sim/stores/terminal/types.ts
index 8cc3fc307..a50196102 100644
--- a/apps/sim/stores/terminal/types.ts
+++ b/apps/sim/stores/terminal/types.ts
@@ -19,6 +19,8 @@ export interface TerminalState {
   setOpenOnRun: (open: boolean) => void
   wrapText: boolean
   setWrapText: (wrap: boolean) => void
+  structuredView: boolean
+  setStructuredView: (structured: boolean) => void
   /**
    * Indicates whether the terminal is currently being resized via mouse drag.
    *