From 156ef676b2c8f89323c285651081f17c6df8af77 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Tue, 27 Jan 2026 11:08:28 -0800 Subject: [PATCH 1/9] feat(code): collapsed JSON in terminal --- .../components/terminal/terminal.tsx | 1 + .../components/emcn/components/code/code.tsx | 719 +++++++++++++----- 2 files changed, 543 insertions(+), 177 deletions(-) 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 bd8334c459..52a42523d7 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 @@ -268,6 +268,7 @@ const OutputCodeContent = React.memo(function OutputCodeContent({ onMatchCountChange={onMatchCountChange} contentRef={contentRef} virtualized + showCollapseColumn={language === 'json'} /> ) }) diff --git a/apps/sim/components/emcn/components/code/code.tsx b/apps/sim/components/emcn/components/code/code.tsx index ee03b04fa1..87430d396b 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,259 @@ 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 + +/** + * 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. + * + * @param lines - Array of code lines + * @returns Map of start line index to CollapsibleRegion + */ +function findCollapsibleRegions(lines: string[]): Map { + const regions = new Map() + const stack: { char: '{' | '['; line: number }[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + 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) + // Check if string is long enough or contains escaped newlines + if (stringValue.length >= MIN_COLLAPSIBLE_STRING_LENGTH || stringValue.includes('\\n')) { + regions.set(i, { startLine: i, endLine: i, type: 'string' }) + } + } + } + } + + // Check for block regions (objects/arrays) + for (const char of line) { + 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()! + // Only create a region if it spans multiple lines + if (i > start.line) { + regions.set(start.line, { + startLine: start.line, + endLine: i, + type: 'block', + }) + } + } + } + } + } + + 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 + 31) + + 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 +515,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 +582,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 +678,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 +736,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 +782,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
   currentMatchIndex,
   onMatchCountChange,
   contentRef,
+  showCollapseColumn,
 }: ViewerInnerProps) {
   const containerRef = useRef(null)
   const listRef = useListRef(null)
@@ -481,52 +793,70 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
     key: wrapText ? 'wrap' : 'nowrap',
   })
 
-  const matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
-
-  useEffect(() => {
-    onMatchCountChange?.(matchCount)
-  }, [matchCount, onMatchCountChange])
-
   const lines = useMemo(() => code.split('\n'), [code])
-  const lineCount = lines.length
-  const gutterWidth = useMemo(() => calculateGutterWidth(lineCount), [lineCount])
+  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 highlightedLines = useMemo(() => {
-    const lang = languages[language] || languages.javascript
-    return lines.map((line, idx) => ({
-      lineNumber: idx + 1,
-      html: highlight(line, lang, language),
-    }))
-  }, [lines, language])
-
-  const matchOffsets = useMemo(() => {
-    if (!searchQuery?.trim()) return []
     const offsets: number[] = []
     let cumulative = 0
     const escaped = escapeRegex(searchQuery)
     const regex = new RegExp(escaped, 'gi')
+    const visibleSet = new Set(visibleLineIndices)
 
-    for (const line of lines) {
+    for (let i = 0; i < lines.length; i++) {
       offsets.push(cumulative)
-      const matches = line.match(regex)
-      cumulative += matches?.length ?? 0
+      // 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 offsets
-  }, [lines, searchQuery])
-
-  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 { matchOffsets: offsets, matchCount: cumulative }
+  }, [lines.length, displayLines, visibleLineIndices, searchQuery])
+
+  useEffect(() => {
+    onMatchCountChange?.(matchCount)
+  }, [matchCount, onMatchCountChange])
+
+  // Only process visible lines for efficiency (not all lines)
+  const visibleLines = useMemo(() => {
+    const lang = languages[language] || languages.javascript
+    const hasSearch = searchQuery?.trim()
+
+    return visibleLineIndices.map((idx) => {
+      let html = highlight(displayLines[idx], lang, language)
+
+      if (hasSearch && searchQuery) {
+        const result = applySearchHighlightingToLine(
+          html,
+          searchQuery,
+          currentMatchIndex,
+          matchOffsets[idx]
+        )
+        html = result.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 +865,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 +882,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 +900,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) => {
@@ -585,14 +914,29 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
 
   const rowProps = useMemo(
     () => ({
-      lines: linesWithSearch,
+      lines: visibleLines,
       gutterWidth,
       showGutter,
       gutterStyle,
       leftOffset: paddingLeft,
       wrapText,
+      showCollapseColumn,
+      collapsibleLines,
+      collapsedLines,
+      onToggleCollapse: toggleCollapse,
     }),
-    [linesWithSearch, gutterWidth, showGutter, gutterStyle, paddingLeft, wrapText]
+    [
+      visibleLines,
+      gutterWidth,
+      showGutter,
+      gutterStyle,
+      paddingLeft,
+      wrapText,
+      showCollapseColumn,
+      collapsibleLines,
+      collapsedLines,
+      toggleCollapse,
+    ]
   )
 
   return (
@@ -609,7 +953,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
       
- *
- * // Virtualized rendering for large outputs
- * 
- * ```
- */
 /**
  * Non-virtualized code viewer implementation.
+ * Renders all lines directly without windowing.
  */
 function ViewerInner({
   code,
@@ -660,23 +980,96 @@ function ViewerInner({
   currentMatchIndex,
   onMatchCountChange,
   contentRef,
+  showCollapseColumn,
 }: ViewerInnerProps) {
-  // Compute match count and notify parent
-  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 { 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])
 
-  // Determine whitespace class based on wrap setting
-  const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
+  // 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) || ' ',
+      }))
+    }
 
-  // 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 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 collapseColumnWidth = showCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0
+
+  // Grid-based rendering for gutter alignment (works with wrap)
+  if (showGutter) {
     return (
       
         
@@ -686,37 +1079,40 @@ function ViewerInner({
               paddingTop: '8px',
               paddingBottom: '8px',
               display: 'grid',
-              gridTemplateColumns: `${gutterWidth}px 1fr`,
+              gridTemplateColumns: showCollapseColumn
+                ? `${gutterWidth}px ${collapseColumnWidth}px 1fr`
+                : `${gutterWidth}px 1fr`,
             }}
           >
-            {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
-                )
-              }
+            {highlightedVisibleLines.map(({ lineNumber, html }) => {
+              const idx = lineNumber - 1
+              const isCollapsible = collapsibleLines.has(idx)
+              const isCollapsed = collapsedLines.has(idx)
 
               return (
                 
                   
- {idx + 1} + {lineNumber}
+ {showCollapseColumn && ( +
+ {isCollapsible && ( + toggleCollapse(idx)} + /> + )} +
+ )}
                 
               )
@@ -727,69 +1123,16 @@ function ViewerInner({
     )
   }
 
-  // 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} -
- ) - } - + // Simple display without gutter return ( - - {lineNumbers} - - +
 0 ? paddingLeft : undefined }}
           dangerouslySetInnerHTML={{ __html: highlightedCode }}
         />
       
@@ -800,6 +1143,26 @@ function ViewerInner({
 /**
  * Readonly code viewer with optional gutter and syntax highlighting.
  * Routes to either standard or virtualized implementation based on the `virtualized` prop.
+ *
+ * @example
+ * ```tsx
+ * // Standard rendering
+ * 
+ *
+ * // Virtualized rendering for large outputs
+ * 
+ * ```
  */
 function Viewer({
   code,
@@ -814,6 +1177,7 @@ function Viewer({
   onMatchCountChange,
   contentRef,
   virtualized = false,
+  showCollapseColumn = false,
 }: CodeViewerProps) {
   const innerProps: ViewerInnerProps = {
     code,
@@ -827,6 +1191,7 @@ function Viewer({
     currentMatchIndex,
     onMatchCountChange,
     contentRef,
+    showCollapseColumn,
   }
 
   return virtualized ?  : 

From 7829fe785c69949ce757ee62ef6b4649e836f927 Mon Sep 17 00:00:00 2001
From: Emir Karabeg 
Date: Tue, 27 Jan 2026 11:47:08 -0800
Subject: [PATCH 2/9] improvement(code): addressed comments

---
 .../components/terminal/components/index.ts   |   2 +-
 .../components}/output-context-menu.tsx       |   0
 .../terminal/components/output-panel/index.ts |   2 +
 .../components/output-panel/output-panel.tsx  | 601 ++++++++++++++++++
 .../components/terminal/terminal.tsx          | 560 +---------------
 .../components/emcn/components/code/code.tsx  |  39 +-
 6 files changed, 641 insertions(+), 563 deletions(-)
 rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/{ => output-panel/components}/output-context-menu.tsx (100%)
 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

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 909a6f743a..45411b9c91 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,2 @@
 export { LogRowContextMenu } from './log-row-context-menu'
-export { OutputContextMenu } from './output-context-menu'
+export { OutputPanel } from './output-panel'
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 100%
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
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 0000000000..38a8c8db66
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts
@@ -0,0 +1,2 @@
+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 0000000000..bdaade36d7
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx
@@ -0,0 +1,601 @@
+'use client'
+
+import React, { useCallback, useEffect, useRef, useState } from 'react'
+import clsx from 'clsx'
+import {
+  ArrowDown,
+  ArrowDownToLine,
+  ArrowUp,
+  Check,
+  ChevronDown,
+  Clipboard,
+  Database,
+  FilterX,
+  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 { OutputContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu'
+import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
+import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
+import type { ConsoleEntry } 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 (
+    
+  )
+})
+
+/**
+ * Reusable toggle button component
+ */
+const ToggleButton = ({
+  isExpanded,
+  onClick,
+}: {
+  isExpanded: boolean
+  onClick: (e: React.MouseEvent) => void
+}) => (
+  
+)
+
+/**
+ * Props for the OutputPanel component
+ */
+export 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.
+ */
+export 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])
+
+  return (
+    <>
+      
+ {/* Horizontal Resize Handle */} +
+ + {/* 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 ? ( + + ) : ( + + )} +
+
+ + {/* Output Panel Context Menu */} + setWrapText(!wrapText)} + openOnRun={openOnRun} + onToggleOpenOnRun={() => setOpenOnRun(!openOnRun)} + onClearConsole={handleClearConsoleFromMenu} + hasSelection={hasSelection} + /> + + ) +}) 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 52a42523d7..addfa24961 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,14 +1,13 @@ '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, @@ -16,18 +15,14 @@ import { 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, Popover, PopoverContent, PopoverItem, @@ -41,7 +36,7 @@ import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/provide import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { LogRowContextMenu, - OutputContextMenu, + OutputPanel, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components' import { useOutputPanelResize, @@ -51,7 +46,6 @@ import { 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' @@ -235,552 +229,6 @@ const isEventFromEditableElement = (e: KeyboardEvent): boolean => { 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 ( - - ) -}) - -/** - * Props for the OutputPanel component - */ -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]) - - return ( - <> -
- {/* Horizontal Resize Handle */} -
- - {/* 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 ? ( - - ) : ( - - )} -
-
- - {/* 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. * @@ -1401,7 +849,7 @@ export const Terminal = memo(function Terminal() { }, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded]) /** - * Handle Escape to unselect entry (search close is handled by useCodeViewerFeatures) + * Handle Escape to unselect entry (search close is handled by OutputPanel internally) * Check if the focused element is in the search overlay to avoid conflicting with search close. */ useEffect(() => { diff --git a/apps/sim/components/emcn/components/code/code.tsx b/apps/sim/components/emcn/components/code/code.tsx index 87430d396b..429167328c 100644 --- a/apps/sim/components/emcn/components/code/code.tsx +++ b/apps/sim/components/emcn/components/code/code.tsx @@ -69,6 +69,11 @@ interface CollapsibleRegion { */ 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. @@ -78,17 +83,20 @@ 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. * * @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('":') @@ -97,23 +105,35 @@ function findCollapsibleRegions(lines: string[]): Map const valueEnd = line.lastIndexOf('"') if (valueStart !== -1 && valueEnd > valueStart) { const stringValue = line.slice(valueStart + 1, valueEnd) - // Check if string is long enough or contains escaped newlines if (stringValue.length >= MIN_COLLAPSIBLE_STRING_LENGTH || stringValue.includes('\\n')) { - regions.set(i, { startLine: i, endLine: i, type: 'string' }) + // Store separately to avoid conflicts with block regions + stringRegions.set(i, { startLine: i, endLine: i, type: 'string' }) } } } } - // Check for block regions (objects/arrays) - for (const char of line) { + // Check for block regions, skipping characters inside strings + let inString = false + for (let j = 0; j < line.length; j++) { + const char = line[j] + const prevChar = j > 0 ? line[j - 1] : '' + + // Toggle string state on unescaped quotes + if (char === '"' && prevChar !== '\\') { + 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()! - // Only create a region if it spans multiple lines if (i > start.line) { regions.set(start.line, { startLine: start.line, @@ -126,6 +146,13 @@ function findCollapsibleRegions(lines: string[]): Map } } + // 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 } @@ -198,7 +225,7 @@ function truncateStringLine(line: string): string { const prefix = line.slice(0, valueStart + 1) const suffix = line.charCodeAt(line.length - 1) === 44 /* ',' */ ? '",' : '"' - const truncated = line.slice(valueStart + 1, valueStart + 31) + const truncated = line.slice(valueStart + 1, valueStart + 1 + MAX_TRUNCATED_STRING_LENGTH) return `${prefix}${truncated}...${suffix}` } From 9eff58ab455a22e5c5330a4d0219283e3630efe1 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Tue, 27 Jan 2026 16:48:41 -0800 Subject: [PATCH 3/9] feat(terminal): added structured output; improvement(preview): note block --- .../components/output-context-menu.tsx | 7 + .../components/structured-output.tsx | 588 ++++++++++++++++++ .../components/output-panel/output-panel.tsx | 30 + .../components/terminal/terminal.tsx | 5 + .../preview-editor/preview-editor.tsx | 20 +- .../components/block/block.tsx | 7 +- .../preview-workflow/preview-workflow.tsx | 4 +- .../components/emcn/components/code/code.tsx | 18 +- apps/sim/stores/terminal/store.ts | 9 + apps/sim/stores/terminal/types.ts | 2 + 10 files changed, 673 insertions(+), 17 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx index 2cb59f9f9a..d3172d170a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx @@ -22,6 +22,8 @@ interface OutputContextMenuProps { onCopySelection: () => void onCopyAll: () => void onSearch: () => void + structuredView: boolean + onToggleStructuredView: () => void wrapText: boolean onToggleWrap: () => void openOnRun: boolean @@ -42,6 +44,8 @@ export function OutputContextMenu({ onCopySelection, onCopyAll, onSearch, + structuredView, + onToggleStructuredView, wrapText, onToggleWrap, openOnRun, @@ -96,6 +100,9 @@ export function OutputContextMenu({ {/* Display settings - toggles don't close menu */} + + Structured View + Wrap Text 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 0000000000..5f2c96faa0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx @@ -0,0 +1,588 @@ +'use client' + +import type React from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { Badge } from '@/components/emcn' +import { 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 passed through the component tree */ +interface SearchContext { + query: string + currentMatchIndex: number + pathToMatchIndices: Map +} + +const BADGE_VARIANTS: Record = { + string: 'green', + number: 'blue', + boolean: 'orange', + array: 'purple', + null: 'gray', + undefined: 'gray', + object: 'gray', +} as const + +const STYLES = { + row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]', + chevron: + 'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]', + keyName: + 'font-medium text-[13px] text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]', + badge: 'rounded-[4px] px-[4px] py-[0px] font-mono text-[11px]', + summary: 'font-mono text-[12px] text-[var(--text-tertiary)]', + indent: 'ml-[3px] border-[var(--border)] border-l pl-[9px]', + value: 'py-[2px] font-mono text-[13px] text-[var(--text-secondary)]', + emptyValue: 'py-[2px] font-mono text-[13px] text-[var(--text-tertiary)]', + matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40', + currentMatchHighlight: 'bg-orange-400', +} as const + +const EMPTY_MATCH_INDICES: number[] = [] + +/** + * Returns the type label for a value + * @param value - The value to get the type label for + * @returns The type label string + */ +function getTypeLabel(value: unknown): ValueType { + if (value === null) return 'null' + if (value === undefined) return 'undefined' + if (Array.isArray(value)) return 'array' + return typeof value as ValueType +} + +/** + * Formats a primitive value for display + * @param value - The primitive value to format + * @returns The formatted string representation + */ +function formatPrimitive(value: unknown): string { + if (value === null) return 'null' + if (value === undefined) return 'undefined' + return String(value) +} + +/** + * Checks if a value is a primitive (not object/array) + * @param value - The value to check + * @returns True if the value is a primitive + */ +function isPrimitive(value: unknown): value is null | undefined | string | number | boolean { + return value === null || value === undefined || typeof value !== 'object' +} + +/** + * Checks if a value is an empty object or array + * @param value - The value to check + * @returns True if the value is empty + */ +function isEmpty(value: unknown): boolean { + if (Array.isArray(value)) return value.length === 0 + if (typeof value === 'object' && value !== null) return Object.keys(value).length === 0 + return false +} + +/** + * Extracts error message from various error data formats + * @param data - The error data to extract message from + * @returns The extracted error message string + */ +function extractErrorMessage(data: unknown): string { + if (typeof data === 'string') return data + if (data instanceof Error) return data.message + if (typeof data === 'object' && data !== null && 'message' in data) { + return String((data as { message: unknown }).message) + } + return JSON.stringify(data, null, 2) +} + +/** + * Builds node entries from an object or array value + * @param value - The object or array to build entries from + * @param basePath - The base path for constructing child paths + * @returns Array of node entries + */ +function buildEntries(value: unknown, basePath: string): NodeEntry[] { + if (Array.isArray(value)) { + 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}`, + })) +} + +/** + * Gets the count summary for collapsed arrays/objects + * @param value - The array or object to summarize + * @returns Summary string or null for primitives + */ +function getCollapsedSummary(value: unknown): string | null { + if (Array.isArray(value)) { + const len = value.length + return `${len} item${len !== 1 ? 's' : ''}` + } + if (typeof value === 'object' && value !== null) { + const count = Object.keys(value).length + return `${count} key${count !== 1 ? 's' : ''}` + } + return null +} + +/** + * Computes initial expanded paths for first-level items + * @param data - The data to compute paths for + * @param isError - Whether this is error data + * @returns Set of initially expanded paths + */ +function computeInitialPaths(data: unknown, isError: boolean): Set { + if (isError) return new Set(['root.error']) + if (!data || typeof data !== 'object') return new Set() + const entries = Array.isArray(data) + ? data.map((_, i) => `root[${i}]`) + : Object.keys(data).map((k) => `root.${k}`) + return new Set(entries) +} + +/** + * Gets all ancestor paths needed to reach a given path + * @param path - The target path + * @returns Array of ancestor paths + */ +function getAncestorPaths(path: string): string[] { + const ancestors: string[] = [] + let current = path + + while (current.includes('.') || current.includes('[')) { + const splitPoint = Math.max(current.lastIndexOf('.'), current.lastIndexOf('[')) + if (splitPoint <= 0) break + current = current.slice(0, splitPoint) + if (current !== 'root') ancestors.push(current) + } + + return ancestors +} + +/** + * Finds all case-insensitive matches of a query within text + * @param text - The text to search in + * @param query - The search query + * @returns Array of [startIndex, endIndex] tuples + */ +function findTextMatches(text: string, query: string): Array<[number, number]> { + if (!query) return [] + + const matches: Array<[number, number]> = [] + const lowerText = text.toLowerCase() + const lowerQuery = query.toLowerCase() + let pos = 0 + + while (pos < lowerText.length) { + const idx = lowerText.indexOf(lowerQuery, pos) + if (idx === -1) break + matches.push([idx, idx + query.length]) + pos = idx + 1 + } + + return matches +} + +/** + * Adds match entries for a primitive value at the given path + * @param value - The primitive value + * @param path - The path to this value + * @param query - The search query + * @param matches - The matches array to add to + */ +function addPrimitiveMatches(value: unknown, path: string, query: string, matches: string[]): void { + const text = formatPrimitive(value) + const count = findTextMatches(text, query).length + for (let i = 0; i < count; i++) { + matches.push(path) + } +} + +/** + * Recursively collects all match paths across the entire data tree + * @param data - The data to search + * @param query - The search query + * @param basePath - The base path for this level + * @returns Array of paths where matches were found + */ +function collectAllMatchPaths(data: unknown, query: string, basePath: string): string[] { + if (!query) return [] + + const matches: string[] = [] + + if (isPrimitive(data)) { + addPrimitiveMatches(data, `${basePath}.value`, query, matches) + return matches + } + + for (const entry of buildEntries(data, basePath)) { + if (isPrimitive(entry.value)) { + addPrimitiveMatches(entry.value, entry.path, query, matches) + } else { + matches.push(...collectAllMatchPaths(entry.value, query, entry.path)) + } + } + + return matches +} + +/** + * Builds a map from path to array of global match indices + * @param matchPaths - Array of paths where matches occur + * @returns Map from path to array of global indices + */ +function buildPathToIndicesMap(matchPaths: string[]): Map { + const map = new Map() + matchPaths.forEach((path, globalIndex) => { + const existing = map.get(path) + if (existing) { + existing.push(globalIndex) + } else { + map.set(path, [globalIndex]) + } + }) + return map +} + +interface HighlightedTextProps { + text: string + searchQuery: string | undefined + matchIndices: number[] + currentMatchIndex: number +} + +/** + * Renders text with search highlights + */ +const HighlightedText = memo(function HighlightedText({ + text, + searchQuery, + matchIndices, + currentMatchIndex, +}: HighlightedTextProps) { + if (!searchQuery || matchIndices.length === 0) return <>{text} + + const textMatches = findTextMatches(text, searchQuery) + 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 StructuredNodeProps { + name: string + value: unknown + path: string + expandedPaths: Set + onToggle: (path: string) => void + wrapText: boolean + isError?: boolean + searchContext?: SearchContext +} + +/** + * Recursive node component for rendering structured data + */ +const StructuredNode = memo(function StructuredNode({ + name, + value, + path, + expandedPaths, + onToggle, + wrapText, + isError = false, + searchContext, +}: StructuredNodeProps) { + 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) => ( + + )) + )} +
+ )} +
+ ) +}) + +interface StructuredOutputProps { + data: unknown + wrapText?: boolean + isError?: boolean + className?: string + searchQuery?: string + currentMatchIndex?: number + onMatchCountChange?: (count: number) => void + contentRef?: React.RefObject +} + +/** + * Renders structured data as nested collapsible blocks. + * Supports search with highlighting, auto-expand, and scroll-to-match. + */ +export const StructuredOutput = memo(function StructuredOutput({ + data, + wrapText = true, + isError = 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 setContainerRef = useCallback( + (node: HTMLDivElement | null) => { + ;(internalRef as React.MutableRefObject).current = node + if (contentRef) { + ;(contentRef as React.MutableRefObject).current = node + } + }, + [contentRef] + ) + + useEffect(() => { + if (prevDataRef.current !== data || prevIsErrorRef.current !== isError) { + prevDataRef.current = data + prevIsErrorRef.current = isError + setExpandedPaths(computeInitialPaths(data, isError)) + } + }, [data, isError]) + + const allMatchPaths = useMemo(() => { + if (!searchQuery) return [] + if (isError) { + const errorText = extractErrorMessage(data) + const count = findTextMatches(errorText, searchQuery).length + return Array(count).fill('root.error') as string[] + } + return collectAllMatchPaths(data, searchQuery, 'root') + }, [data, searchQuery, isError]) + + useEffect(() => { + onMatchCountChange?.(allMatchPaths.length) + }, [allMatchPaths.length, onMatchCountChange]) + + const pathToMatchIndices = useMemo(() => buildPathToIndicesMap(allMatchPaths), [allMatchPaths]) + + useEffect(() => { + if ( + allMatchPaths.length === 0 || + currentMatchIndex < 0 || + currentMatchIndex >= allMatchPaths.length + ) { + return + } + + const currentPath = allMatchPaths[currentMatchIndex] + const pathsToExpand = [currentPath, ...getAncestorPaths(currentPath)] + + setExpandedPaths((prev) => { + if (pathsToExpand.every((p) => prev.has(p))) return prev + const next = new Set(prev) + pathsToExpand.forEach((p) => next.add(p)) + return next + }) + }, [currentMatchIndex, allMatchPaths]) + + useEffect(() => { + if (allMatchPaths.length === 0) return + + const rafId = requestAnimationFrame(() => { + const match = internalRef.current?.querySelector( + `[data-match-index="${currentMatchIndex}"]` + ) as HTMLElement | null + match?.scrollIntoView({ block: 'center', behavior: 'smooth' }) + }) + + return () => cancelAnimationFrame(rafId) + }, [currentMatchIndex, allMatchPaths.length, expandedPaths]) + + const handleToggle = useCallback((path: string) => { + setExpandedPaths((prev) => { + const next = new Set(prev) + if (next.has(path)) { + next.delete(path) + } else { + next.add(path) + } + return next + }) + }, []) + + const rootEntries = useMemo(() => { + if (isPrimitive(data)) { + return [{ key: 'value', value: data, path: 'root.value' }] + } + return buildEntries(data, 'root') + }, [data]) + + const searchContext = useMemo(() => { + if (!searchQuery) return undefined + return { query: searchQuery, currentMatchIndex, pathToMatchIndices } + }, [searchQuery, currentMatchIndex, pathToMatchIndices]) + + const containerClass = cn('flex flex-col pl-[20px]', className) + + if (isError) { + return ( +
+ +
+ ) + } + + return ( +
+ {rootEntries.map((entry) => ( + + ))} +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx index bdaade36d7..fdf7358bd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -30,6 +30,7 @@ import { Tooltip, } from '@/components/emcn' 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 { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import type { ConsoleEntry } from '@/stores/terminal' @@ -120,10 +121,13 @@ export interface OutputPanelProps { setWrapText: (wrap: boolean) => void openOnRun: boolean setOpenOnRun: (open: boolean) => void + structuredView: boolean + setStructuredView: (structured: boolean) => void outputOptionsOpen: boolean setOutputOptionsOpen: (open: boolean) => void shouldShowCodeDisplay: boolean outputDataStringified: string + outputData: unknown handleClearConsoleFromMenu: () => void } @@ -155,10 +159,13 @@ export const OutputPanel = React.memo(function OutputPanel({ setWrapText, openOnRun, setOpenOnRun, + structuredView, + setStructuredView, outputOptionsOpen, setOutputOptionsOpen, shouldShowCodeDisplay, outputDataStringified, + outputData, handleClearConsoleFromMenu, }: OutputPanelProps) { const outputContentRef = useRef(null) @@ -466,6 +473,16 @@ export const OutputPanel = React.memo(function OutputPanel({ style={{ minWidth: '140px', maxWidth: '160px' }} className='gap-[2px]' > + { + e.stopPropagation() + setStructuredView(!structuredView) + }} + > + Structured view + + ) : structuredView ? ( + ) : ( setStructuredView(!structuredView)} wrapText={wrapText} onToggleWrap={() => setWrapText(!wrapText)} openOnRun={openOnRun} 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 addfa24961..678a9f0419 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 @@ -255,6 +255,8 @@ export const Terminal = memo(function Terminal() { const setOpenOnRun = useTerminalStore((state) => state.setOpenOnRun) const wrapText = useTerminalStore((state) => state.wrapText) const setWrapText = useTerminalStore((state) => state.setWrapText) + const structuredView = useTerminalStore((state) => state.structuredView) + const setStructuredView = useTerminalStore((state) => state.setStructuredView) const setHasHydrated = useTerminalStore((state) => state.setHasHydrated) const isExpanded = useTerminalStore((state) => state.terminalHeight > NEAR_MIN_THRESHOLD) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -1411,10 +1413,13 @@ export const Terminal = memo(function Terminal() { setWrapText={setWrapText} openOnRun={openOnRun} setOpenOnRun={setOpenOnRun} + structuredView={structuredView} + setStructuredView={setStructuredView} outputOptionsOpen={outputOptionsOpen} setOutputOptionsOpen={setOutputOptionsOpen} shouldShowCodeDisplay={shouldShowCodeDisplay} outputDataStringified={outputDataStringified} + outputData={outputData} handleClearConsoleFromMenu={handleClearConsoleFromMenu} /> )} 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 b6a01a646d..8e4839ef9a 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 ca0c3ed5db..a484f15cea 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 && ( <> let inString = false for (let j = 0; j < line.length; j++) { const char = line[j] - const prevChar = j > 0 ? line[j - 1] : '' // Toggle string state on unescaped quotes - if (char === '"' && prevChar !== '\\') { - inString = !inString + // 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 } diff --git a/apps/sim/stores/terminal/store.ts b/apps/sim/stores/terminal/store.ts index faf42d25df..6d8ea91c98 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 8cc3fc3074..a50196102f 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. * From 86653cadb5d586e7cde35262dc8f5389fcfe45da Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Wed, 28 Jan 2026 00:53:45 -0800 Subject: [PATCH 4/9] feat(terminal): log view --- .../components/trace-spans/trace-spans.tsx | 16 +- .../connection-blocks/connection-blocks.tsx | 8 +- .../filter-popover/filter-popover.tsx | 160 ++ .../components/filter-popover/index.ts | 1 + .../components/terminal/components/index.ts | 6 +- .../components/log-row-context-menu/index.ts | 1 + .../log-row-context-menu.tsx | 23 +- .../components/output-context-menu.tsx | 14 +- .../components/structured-output.tsx | 213 +-- .../terminal/components/output-panel/index.ts | 2 + .../components/output-panel/output-panel.tsx | 255 +-- .../components/toggle-button/index.ts | 1 + .../toggle-button/toggle-button.tsx | 33 + .../components/terminal/hooks/index.ts | 1 + .../terminal/hooks/use-terminal-filters.ts | 24 +- .../components/terminal/terminal.tsx | 1486 ++++++++++------- .../components/terminal/types.tsx | 111 ++ .../[workflowId]/components/terminal/utils.ts | 488 ++++++ .../hooks/use-workflow-execution.ts | 107 +- .../components/emcn/components/code/code.tsx | 16 +- apps/sim/stores/terminal/console/store.ts | 37 + apps/sim/stores/terminal/console/types.ts | 13 + 22 files changed, 2086 insertions(+), 930 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/index.ts rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/{ => log-row-context-menu}/log-row-context-menu.tsx (93%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx index dab65614c5..c5988dc230 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx @@ -573,7 +573,19 @@ const TraceSpanNode = memo(function TraceSpanNode({ return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime)) }, [span, spanId, spanStartTime]) - const hasChildren = allChildren.length > 0 + // Hide empty model timing segments for agents without tool calls + const filteredChildren = useMemo(() => { + const isAgent = span.type?.toLowerCase() === 'agent' + const hasToolCalls = + (span.toolCalls?.length ?? 0) > 0 || allChildren.some((c) => c.type?.toLowerCase() === 'tool') + + if (isAgent && !hasToolCalls) { + return allChildren.filter((c) => c.type?.toLowerCase() !== 'model') + } + return allChildren + }, [allChildren, span.type, span.toolCalls]) + + const hasChildren = filteredChildren.length > 0 const isExpanded = isRootWorkflow || expandedNodes.has(spanId) const isToggleable = !isRootWorkflow @@ -685,7 +697,7 @@ const TraceSpanNode = memo(function TraceSpanNode({ {/* Nested Children */} {hasChildren && (
- {allChildren.map((child, index) => ( + {filteredChildren.map((child, index) => (
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx new file mode 100644 index 0000000000..a0312bf5fd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx @@ -0,0 +1,160 @@ +'use client' + +import { memo } from 'react' +import clsx from 'clsx' +import { Filter } from 'lucide-react' +import { + Button, + Popover, + PopoverContent, + PopoverDivider, + PopoverItem, + PopoverScrollArea, + PopoverSection, + PopoverTrigger, +} from '@/components/emcn' +import type { + BlockInfo, + TerminalFilters, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' +import { + formatRunId, + getBlockIcon, + getRunIdColor, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' + +/** + * Props for the FilterPopover component + */ +export interface FilterPopoverProps { + open: boolean + onOpenChange: (open: boolean) => void + filters: TerminalFilters + toggleStatus: (status: 'error' | 'info') => void + toggleBlock: (blockId: string) => void + toggleRunId: (runId: string) => void + uniqueBlocks: BlockInfo[] + uniqueRunIds: string[] + executionColorMap: Map + hasActiveFilters: boolean +} + +/** + * Filter popover component used in terminal header and output panel + */ +export const FilterPopover = memo(function FilterPopover({ + open, + onOpenChange, + filters, + toggleStatus, + toggleBlock, + toggleRunId, + uniqueBlocks, + uniqueRunIds, + executionColorMap, + hasActiveFilters, +}: FilterPopoverProps) { + return ( + + + + + e.stopPropagation()} + minWidth={160} + maxWidth={220} + maxHeight={300} + > + Status + toggleStatus('error')} + > +
+ Error + + toggleStatus('info')} + > +
+ Info + + + {uniqueBlocks.length > 0 && ( + <> + + Blocks + + {uniqueBlocks.map((block) => { + const BlockIcon = getBlockIcon(block.blockType) + const isSelected = filters.blockIds.has(block.blockId) + + return ( + toggleBlock(block.blockId)} + > + {BlockIcon && } + {block.blockName} + + ) + })} + + + )} + + {uniqueRunIds.length > 0 && ( + <> + + Run ID + + {uniqueRunIds.map((runId) => { + const isSelected = filters.runIds.has(runId) + const runIdColor = getRunIdColor(runId, executionColorMap) + + return ( + toggleRunId(runId)} + > + + {formatRunId(runId)} + + + ) + })} + + + )} + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/index.ts new file mode 100644 index 0000000000..3804f73bb5 --- /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 45411b9c91..60a2038274 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts @@ -1,2 +1,4 @@ -export { LogRowContextMenu } from './log-row-context-menu' -export { OutputPanel } from './output-panel' +export { FilterPopover, type FilterPopoverProps } from './filter-popover' +export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu' +export { OutputPanel, type OutputPanelProps } from './output-panel' +export { ToggleButton, type ToggleButtonProps } from './toggle-button' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/index.ts new file mode 100644 index 0000000000..98d5a2b6be --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/index.ts @@ -0,0 +1 @@ +export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx similarity index 93% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx index ff4fd71d12..b65b5ab767 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx @@ -1,6 +1,6 @@ 'use client' -import type { RefObject } from 'react' +import { memo, type RefObject } from 'react' import { Popover, PopoverAnchor, @@ -8,20 +8,13 @@ import { PopoverDivider, PopoverItem, } from '@/components/emcn' +import type { + ContextMenuPosition, + TerminalFilters, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' import type { ConsoleEntry } from '@/stores/terminal' -interface ContextMenuPosition { - x: number - y: number -} - -interface TerminalFilters { - blockIds: Set - statuses: Set<'error' | 'info'> - runIds: Set -} - -interface LogRowContextMenuProps { +export interface LogRowContextMenuProps { isOpen: boolean position: ContextMenuPosition menuRef: RefObject @@ -42,7 +35,7 @@ interface LogRowContextMenuProps { * Context menu for terminal log rows (left side). * Displays filtering options based on the selected row's properties. */ -export function LogRowContextMenu({ +export const LogRowContextMenu = memo(function LogRowContextMenu({ isOpen, position, menuRef, @@ -173,4 +166,4 @@ export function LogRowContextMenu({ ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx index d3172d170a..0b3288cda9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx @@ -1,6 +1,6 @@ 'use client' -import type { RefObject } from 'react' +import { memo, type RefObject } from 'react' import { Popover, PopoverAnchor, @@ -8,13 +8,9 @@ import { PopoverDivider, PopoverItem, } from '@/components/emcn' +import type { ContextMenuPosition } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' -interface ContextMenuPosition { - x: number - y: number -} - -interface OutputContextMenuProps { +export interface OutputContextMenuProps { isOpen: boolean position: ContextMenuPosition menuRef: RefObject @@ -36,7 +32,7 @@ interface OutputContextMenuProps { * Context menu for terminal output panel (right side). * Displays copy, search, and display options for the code viewer. */ -export function OutputContextMenu({ +export const OutputContextMenu = memo(function OutputContextMenu({ isOpen, position, menuRef, @@ -123,4 +119,4 @@ export function OutputContextMenu({ ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx index 5f2c96faa0..678dd6a4b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx @@ -1,9 +1,17 @@ 'use client' import type React from 'react' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ChevronDown } from 'lucide-react' -import { Badge } from '@/components/emcn' +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { Badge, ChevronDown } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' type ValueType = 'null' | 'undefined' | 'array' | 'string' | 'number' | 'boolean' | 'object' @@ -15,13 +23,19 @@ interface NodeEntry { path: string } -/** Search context passed through the component tree */ -interface SearchContext { +/** + * Search context for the structured output tree. + * Separates stable values (query, pathToMatchIndices) from frequently changing currentMatchIndex + * to avoid unnecessary re-renders of the entire tree. + */ +interface SearchContextValue { query: string - currentMatchIndex: number pathToMatchIndices: Map + currentMatchIndexRef: React.RefObject } +const SearchContext = createContext(null) + const BADGE_VARIANTS: Record = { string: 'green', number: 'blue', @@ -33,16 +47,17 @@ const BADGE_VARIANTS: Record = { } as const const STYLES = { - row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]', + row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] rounded-[8px] px-[6px] -mx-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]', chevron: 'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]', keyName: - 'font-medium text-[13px] text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]', - badge: 'rounded-[4px] px-[4px] py-[0px] font-mono text-[11px]', - summary: 'font-mono text-[12px] text-[var(--text-tertiary)]', - indent: 'ml-[3px] border-[var(--border)] border-l pl-[9px]', - value: 'py-[2px] font-mono text-[13px] text-[var(--text-secondary)]', - emptyValue: 'py-[2px] font-mono text-[13px] text-[var(--text-tertiary)]', + 'font-medium text-[13px] text-[var(--text-primary)] group-hover:text-[var(--text-primary)]', + badge: 'rounded-[4px] px-[4px] py-[0px] text-[11px]', + summary: 'text-[12px] text-[var(--text-tertiary)]', + indent: + 'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]', + value: 'py-[2px] text-[13px] text-[var(--text-primary)]', + emptyValue: 'py-[2px] text-[13px] text-[var(--text-tertiary)]', matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40', currentMatchHighlight: 'bg-orange-400', } as const @@ -51,8 +66,6 @@ const EMPTY_MATCH_INDICES: number[] = [] /** * Returns the type label for a value - * @param value - The value to get the type label for - * @returns The type label string */ function getTypeLabel(value: unknown): ValueType { if (value === null) return 'null' @@ -63,8 +76,6 @@ function getTypeLabel(value: unknown): ValueType { /** * Formats a primitive value for display - * @param value - The primitive value to format - * @returns The formatted string representation */ function formatPrimitive(value: unknown): string { if (value === null) return 'null' @@ -74,8 +85,6 @@ function formatPrimitive(value: unknown): string { /** * Checks if a value is a primitive (not object/array) - * @param value - The value to check - * @returns True if the value is a primitive */ function isPrimitive(value: unknown): value is null | undefined | string | number | boolean { return value === null || value === undefined || typeof value !== 'object' @@ -83,8 +92,6 @@ function isPrimitive(value: unknown): value is null | undefined | string | numbe /** * Checks if a value is an empty object or array - * @param value - The value to check - * @returns True if the value is empty */ function isEmpty(value: unknown): boolean { if (Array.isArray(value)) return value.length === 0 @@ -94,8 +101,6 @@ function isEmpty(value: unknown): boolean { /** * Extracts error message from various error data formats - * @param data - The error data to extract message from - * @returns The extracted error message string */ function extractErrorMessage(data: unknown): string { if (typeof data === 'string') return data @@ -108,9 +113,6 @@ function extractErrorMessage(data: unknown): string { /** * Builds node entries from an object or array value - * @param value - The object or array to build entries from - * @param basePath - The base path for constructing child paths - * @returns Array of node entries */ function buildEntries(value: unknown, basePath: string): NodeEntry[] { if (Array.isArray(value)) { @@ -125,8 +127,6 @@ function buildEntries(value: unknown, basePath: string): NodeEntry[] { /** * Gets the count summary for collapsed arrays/objects - * @param value - The array or object to summarize - * @returns Summary string or null for primitives */ function getCollapsedSummary(value: unknown): string | null { if (Array.isArray(value)) { @@ -142,9 +142,6 @@ function getCollapsedSummary(value: unknown): string | null { /** * Computes initial expanded paths for first-level items - * @param data - The data to compute paths for - * @param isError - Whether this is error data - * @returns Set of initially expanded paths */ function computeInitialPaths(data: unknown, isError: boolean): Set { if (isError) return new Set(['root.error']) @@ -157,8 +154,6 @@ function computeInitialPaths(data: unknown, isError: boolean): Set { /** * Gets all ancestor paths needed to reach a given path - * @param path - The target path - * @returns Array of ancestor paths */ function getAncestorPaths(path: string): string[] { const ancestors: string[] = [] @@ -176,9 +171,6 @@ function getAncestorPaths(path: string): string[] { /** * Finds all case-insensitive matches of a query within text - * @param text - The text to search in - * @param query - The search query - * @returns Array of [startIndex, endIndex] tuples */ function findTextMatches(text: string, query: string): Array<[number, number]> { if (!query) return [] @@ -200,10 +192,6 @@ function findTextMatches(text: string, query: string): Array<[number, number]> { /** * Adds match entries for a primitive value at the given path - * @param value - The primitive value - * @param path - The path to this value - * @param query - The search query - * @param matches - The matches array to add to */ function addPrimitiveMatches(value: unknown, path: string, query: string, matches: string[]): void { const text = formatPrimitive(value) @@ -215,10 +203,6 @@ function addPrimitiveMatches(value: unknown, path: string, query: string, matche /** * Recursively collects all match paths across the entire data tree - * @param data - The data to search - * @param query - The search query - * @param basePath - The base path for this level - * @returns Array of paths where matches were found */ function collectAllMatchPaths(data: unknown, query: string, basePath: string): string[] { if (!query) return [] @@ -243,8 +227,6 @@ function collectAllMatchPaths(data: unknown, query: string, basePath: string): s /** * Builds a map from path to array of global match indices - * @param matchPaths - Array of paths where matches occur - * @returns Map from path to array of global indices */ function buildPathToIndicesMap(matchPaths: string[]): Map { const map = new Map() @@ -261,25 +243,28 @@ function buildPathToIndicesMap(matchPaths: string[]): Map { interface HighlightedTextProps { text: string - searchQuery: string | undefined matchIndices: number[] - currentMatchIndex: number + path: string } /** - * Renders text with search highlights + * Renders text with search highlights. + * Uses context to access search state and avoid prop drilling. */ const HighlightedText = memo(function HighlightedText({ text, - searchQuery, matchIndices, - currentMatchIndex, + path, }: HighlightedTextProps) { - if (!searchQuery || matchIndices.length === 0) return <>{text} + const searchContext = useContext(SearchContext) - const textMatches = findTextMatches(text, searchQuery) + if (!searchContext || matchIndices.length === 0) return <>{text} + + const textMatches = findTextMatches(text, searchContext.query) if (textMatches.length === 0) return <>{text} + const currentMatchIndex = searchContext.currentMatchIndexRef.current + const segments: React.ReactNode[] = [] let lastEnd = 0 @@ -288,12 +273,12 @@ const HighlightedText = memo(function HighlightedText({ const isCurrent = globalIndex === currentMatchIndex if (start > lastEnd) { - segments.push({text.slice(lastEnd, start)}) + segments.push({text.slice(lastEnd, start)}) } segments.push( {text.slice(lastEnd)}) + segments.push({text.slice(lastEnd)}) } return <>{segments} @@ -322,11 +307,11 @@ interface StructuredNodeProps { onToggle: (path: string) => void wrapText: boolean isError?: boolean - searchContext?: SearchContext } /** - * Recursive node component for rendering structured data + * Recursive node component for rendering structured data. + * Uses context for search state to avoid re-renders when currentMatchIndex changes. */ const StructuredNode = memo(function StructuredNode({ name, @@ -336,8 +321,8 @@ const StructuredNode = memo(function StructuredNode({ onToggle, wrapText, isError = false, - searchContext, }: StructuredNodeProps) { + const searchContext = useContext(SearchContext) const type = getTypeLabel(value) const isPrimitiveValue = isPrimitive(value) const isEmptyValue = !isPrimitiveValue && isEmpty(value) @@ -379,7 +364,6 @@ const StructuredNode = memo(function StructuredNode({ tabIndex={0} aria-expanded={isExpanded} > - {name} {type} @@ -387,6 +371,7 @@ const StructuredNode = memo(function StructuredNode({ {!isExpanded && collapsedSummary && ( {collapsedSummary} )} +
{isExpanded && ( @@ -398,12 +383,7 @@ const StructuredNode = memo(function StructuredNode({ wrapText ? '[word-break:break-word]' : 'whitespace-nowrap' )} > - +
) : isEmptyValue ? (
{Array.isArray(value) ? '[]' : '{}'}
@@ -417,7 +397,6 @@ const StructuredNode = memo(function StructuredNode({ expandedPaths={expandedPaths} onToggle={onToggle} wrapText={wrapText} - searchContext={searchContext} /> )) )} @@ -427,10 +406,11 @@ const StructuredNode = memo(function StructuredNode({ ) }) -interface StructuredOutputProps { +export interface StructuredOutputProps { data: unknown wrapText?: boolean isError?: boolean + isRunning?: boolean className?: string searchQuery?: string currentMatchIndex?: number @@ -441,11 +421,13 @@ interface StructuredOutputProps { /** * Renders structured data as nested collapsible blocks. * Supports search with highlighting, auto-expand, and scroll-to-match. + * Uses React Context for search state to prevent re-render cascade. */ export const StructuredOutput = memo(function StructuredOutput({ data, wrapText = true, isError = false, + isRunning = false, className, searchQuery, currentMatchIndex = 0, @@ -458,6 +440,16 @@ export const StructuredOutput = memo(function StructuredOutput({ const prevDataRef = useRef(data) const prevIsErrorRef = useRef(isError) const internalRef = useRef(null) + const currentMatchIndexRef = useRef(currentMatchIndex) + + // Keep ref in sync + currentMatchIndexRef.current = currentMatchIndex + + // Force re-render of highlighted text when currentMatchIndex changes + const [, forceUpdate] = useState(0) + useEffect(() => { + forceUpdate((n) => n + 1) + }, [currentMatchIndex]) const setContainerRef = useCallback( (node: HTMLDivElement | null) => { @@ -545,44 +537,73 @@ export const StructuredOutput = memo(function StructuredOutput({ return buildEntries(data, 'root') }, [data]) - const searchContext = useMemo(() => { - if (!searchQuery) return undefined - return { query: searchQuery, currentMatchIndex, pathToMatchIndices } - }, [searchQuery, currentMatchIndex, pathToMatchIndices]) + // Create stable search context value - only changes when query or pathToMatchIndices change + const searchContextValue = useMemo(() => { + if (!searchQuery) return null + return { + query: searchQuery, + pathToMatchIndices, + currentMatchIndexRef, + } + }, [searchQuery, pathToMatchIndices]) const containerClass = cn('flex flex-col pl-[20px]', className) + // Show "Running" badge when running with undefined data + if (isRunning && data === undefined) { + return ( +
+
+ running + + Running + +
+
+ ) + } + if (isError) { + return ( + +
+ +
+
+ ) + } + + if (rootEntries.length === 0) { return (
- + null
) } return ( -
- {rootEntries.map((entry) => ( - - ))} -
+ +
+ {rootEntries.map((entry) => ( + + ))} +
+
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts index 38a8c8db66..20fe06c259 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts @@ -1,2 +1,4 @@ +export { OutputContextMenu, type OutputContextMenuProps } from './components/output-context-menu' +export { StructuredOutput, type StructuredOutputProps } from './components/structured-output' export type { OutputPanelProps } from './output-panel' export { OutputPanel } from './output-panel' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx index fdf7358bd0..7fbeb73295 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -1,13 +1,12 @@ 'use client' -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { ArrowDown, ArrowDownToLine, ArrowUp, Check, - ChevronDown, Clipboard, Database, FilterX, @@ -29,11 +28,18 @@ import { PopoverTrigger, Tooltip, } from '@/components/emcn' +import { FilterPopover } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover' import { OutputContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu' import { StructuredOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output' +import { ToggleButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button' +import type { + BlockInfo, + TerminalFilters, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import type { ConsoleEntry } from '@/stores/terminal' +import { useTerminalStore } from '@/stores/terminal' interface OutputCodeContentProps { code: string @@ -73,32 +79,13 @@ const OutputCodeContent = React.memo(function OutputCodeContent({ ) }) -/** - * Reusable toggle button component - */ -const ToggleButton = ({ - isExpanded, - onClick, -}: { - isExpanded: boolean - onClick: (e: React.MouseEvent) => void -}) => ( - -) - /** * Props for the OutputPanel component + * Store-backed settings (wrapText, openOnRun, structuredView, outputPanelWidth) + * are accessed directly from useTerminalStore to reduce prop drilling. */ export interface OutputPanelProps { selectedEntry: ConsoleEntry - outputPanelWidth: number handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void handleHeaderClick: () => void isExpanded: boolean @@ -117,26 +104,25 @@ export interface OutputPanelProps { hasActiveFilters: boolean clearFilters: () => void handleClearConsole: (e: React.MouseEvent) => void - wrapText: boolean - setWrapText: (wrap: boolean) => void - openOnRun: boolean - setOpenOnRun: (open: boolean) => void - structuredView: boolean - setStructuredView: (structured: boolean) => void - outputOptionsOpen: boolean - setOutputOptionsOpen: (open: boolean) => void shouldShowCodeDisplay: boolean outputDataStringified: string outputData: unknown handleClearConsoleFromMenu: () => void + filters: TerminalFilters + toggleBlock: (blockId: string) => void + toggleStatus: (status: 'error' | 'info') => void + toggleRunId: (runId: string) => void + uniqueBlocks: BlockInfo[] + uniqueRunIds: string[] + executionColorMap: Map } /** * Output panel component that manages its own search state. + * Accesses store-backed settings directly to reduce prop drilling. */ export const OutputPanel = React.memo(function OutputPanel({ selectedEntry, - outputPanelWidth, handleOutputPanelResizeMouseDown, handleHeaderClick, isExpanded, @@ -155,20 +141,30 @@ export const OutputPanel = React.memo(function OutputPanel({ hasActiveFilters, clearFilters, handleClearConsole, - wrapText, - setWrapText, - openOnRun, - setOpenOnRun, - structuredView, - setStructuredView, - outputOptionsOpen, - setOutputOptionsOpen, shouldShowCodeDisplay, outputDataStringified, outputData, handleClearConsoleFromMenu, + filters, + toggleBlock, + toggleStatus, + toggleRunId, + uniqueBlocks, + uniqueRunIds, + executionColorMap, }: OutputPanelProps) { + // Access store-backed settings directly to reduce prop drilling + const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth) + const wrapText = useTerminalStore((state) => state.wrapText) + const setWrapText = useTerminalStore((state) => state.setWrapText) + const openOnRun = useTerminalStore((state) => state.openOnRun) + const setOpenOnRun = useTerminalStore((state) => state.setOpenOnRun) + const structuredView = useTerminalStore((state) => state.structuredView) + const setStructuredView = useTerminalStore((state) => state.setStructuredView) + const outputContentRef = useRef(null) + const [filtersOpen, setFiltersOpen] = useState(false) + const [outputOptionsOpen, setOutputOptionsOpen] = useState(false) const { isSearchActive: isOutputSearchActive, searchQuery: outputSearchQuery, @@ -215,6 +211,81 @@ export const OutputPanel = React.memo(function OutputPanel({ } }, [storedSelectionText]) + // Memoized callbacks to avoid inline arrow functions + const handleToggleStructuredView = useCallback(() => { + setStructuredView(!structuredView) + }, [structuredView, setStructuredView]) + + const handleToggleWrapText = useCallback(() => { + setWrapText(!wrapText) + }, [wrapText, setWrapText]) + + const handleToggleOpenOnRun = useCallback(() => { + setOpenOnRun(!openOnRun) + }, [openOnRun, setOpenOnRun]) + + const handleClearFiltersClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + clearFilters() + }, + [clearFilters] + ) + + const handleCopyClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + handleCopy() + }, + [handleCopy] + ) + + const handleSearchClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + activateOutputSearch() + }, + [activateOutputSearch] + ) + + const handleCloseSearchClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + closeOutputSearch() + }, + [closeOutputSearch] + ) + + const handleOutputButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!isExpanded) { + expandToLastHeight() + } + if (showInput) setShowInput(false) + }, + [isExpanded, expandToLastHeight, showInput, setShowInput] + ) + + const handleInputButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!isExpanded) { + expandToLastHeight() + } + setShowInput(true) + }, + [isExpanded, expandToLastHeight, setShowInput] + ) + + const handleToggleButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + handleHeaderClick() + }, + [handleHeaderClick] + ) + /** * Track text selection state for context menu. * Skip updates when the context menu is open to prevent the selection @@ -232,6 +303,12 @@ export const OutputPanel = React.memo(function OutputPanel({ return () => document.removeEventListener('selectionchange', handleSelectionChange) }, [isOutputMenuOpen]) + // Memoize the search query for structured output to avoid re-renders + const structuredSearchQuery = useMemo( + () => (isOutputSearchActive ? outputSearchQuery : undefined), + [isOutputSearchActive, outputSearchQuery] + ) + return ( <>
{ - e.stopPropagation() - if (!isExpanded) { - expandToLastHeight() - } - if (showInput) setShowInput(false) - }} + onClick={handleOutputButtonClick} aria-label='Show output' > Output @@ -277,13 +348,7 @@ export const OutputPanel = React.memo(function OutputPanel({ 'px-[8px] py-[6px] text-[12px]', showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]' )} - onClick={(e) => { - e.stopPropagation() - if (!isExpanded) { - expandToLastHeight() - } - setShowInput(true) - }} + onClick={handleInputButtonClick} aria-label='Show input' > Input @@ -291,16 +356,29 @@ export const OutputPanel = React.memo(function OutputPanel({ )}
+ {/* Unified filter popover */} + {filteredEntries.length > 0 && ( + + )} + {isOutputSearchActive ? (
@@ -578,7 +626,7 @@ export const OutputPanel = React.memo(function OutputPanel({ code={selectedEntry.input.code} language={(selectedEntry.input.language as 'javascript' | 'json') || 'javascript'} wrapText={wrapText} - searchQuery={isOutputSearchActive ? outputSearchQuery : undefined} + searchQuery={structuredSearchQuery} currentMatchIndex={currentMatchIndex} onMatchCountChange={handleMatchCountChange} contentRef={outputContentRef} @@ -588,8 +636,9 @@ export const OutputPanel = React.memo(function OutputPanel({ data={outputData} wrapText={wrapText} isError={!showInput && Boolean(selectedEntry.error)} + isRunning={!showInput && Boolean(selectedEntry.isRunning)} className='min-h-full' - searchQuery={isOutputSearchActive ? outputSearchQuery : undefined} + searchQuery={structuredSearchQuery} currentMatchIndex={currentMatchIndex} onMatchCountChange={handleMatchCountChange} contentRef={outputContentRef} @@ -599,7 +648,7 @@ export const OutputPanel = React.memo(function OutputPanel({ code={outputDataStringified} language='json' wrapText={wrapText} - searchQuery={isOutputSearchActive ? outputSearchQuery : undefined} + searchQuery={structuredSearchQuery} currentMatchIndex={currentMatchIndex} onMatchCountChange={handleMatchCountChange} contentRef={outputContentRef} @@ -618,11 +667,11 @@ export const OutputPanel = React.memo(function OutputPanel({ onCopyAll={handleCopy} onSearch={activateOutputSearch} structuredView={structuredView} - onToggleStructuredView={() => setStructuredView(!structuredView)} + onToggleStructuredView={handleToggleStructuredView} wrapText={wrapText} - onToggleWrap={() => setWrapText(!wrapText)} + onToggleWrap={handleToggleWrapText} openOnRun={openOnRun} - onToggleOpenOnRun={() => setOpenOnRun(!openOnRun)} + onToggleOpenOnRun={handleToggleOpenOnRun} onClearConsole={handleClearConsoleFromMenu} hasSelection={hasSelection} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/index.ts new file mode 100644 index 0000000000..53f376c1ff --- /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 0000000000..43b2c9dc3c --- /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 0043dd8960..adf2b16072 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/index.ts @@ -1,3 +1,4 @@ +export type { SortConfig, SortDirection, SortField, TerminalFilters } from '../types' export { useOutputPanelResize } from './use-output-panel-resize' export { useTerminalFilters } from './use-terminal-filters' export { useTerminalResize } from './use-terminal-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts index 499af0f736..e5e6119270 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts @@ -1,26 +1,10 @@ import { useCallback, useMemo, useState } from 'react' +import type { + SortConfig, + TerminalFilters, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' import type { ConsoleEntry } from '@/stores/terminal' -/** - * Sort configuration - */ -export type SortField = 'timestamp' -export type SortDirection = 'asc' | 'desc' - -export interface SortConfig { - field: SortField - direction: SortDirection -} - -/** - * Filter configuration state - */ -export interface TerminalFilters { - blockIds: Set - statuses: Set<'error' | 'info'> - runIds: Set -} - /** * Custom hook to manage terminal filters and sorting. * Provides filter state, sort state, and filtering/sorting logic for console entries. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 678a9f0419..49b78c9fb2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -7,44 +7,58 @@ import { ArrowDown, ArrowDownToLine, ArrowUp, - ChevronDown, Database, - Filter, FilterX, MoreHorizontal, Palette, Pause, - RepeatIcon, - SplitIcon, Trash2, } from 'lucide-react' import Link from 'next/link' -import { useShallow } from 'zustand/react/shallow' import { Badge, Button, + ChevronDown, Popover, PopoverContent, PopoverItem, - PopoverScrollArea, PopoverTrigger, Tooltip, } from '@/components/emcn' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { formatTimeWithSeconds } from '@/lib/core/utils/formatting' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { + FilterPopover, LogRowContextMenu, OutputPanel, + ToggleButton, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components' import { useOutputPanelResize, useTerminalFilters, useTerminalResize, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks' +import { + BADGE_STYLES, + ROW_STYLES, + StatusDisplay, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' +import { + type EntryNode, + type ExecutionGroup, + flattenBlockEntriesOnly, + formatDuration, + formatRunId, + getBlockColor, + getBlockIcon, + groupEntriesByExecution, + isEventFromEditableElement, + type NavigableBlockEntry, + RUN_ID_COLORS, + TERMINAL_CONFIG, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { getBlock } from '@/blocks' import { useShowTrainingControls } from '@/hooks/queries/general-settings' import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants' import { useCopilotTrainingStore } from '@/stores/copilot-training/store' @@ -57,197 +71,474 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' * Terminal height configuration constants */ const MIN_HEIGHT = TERMINAL_HEIGHT.MIN -const NEAR_MIN_THRESHOLD = 40 const DEFAULT_EXPANDED_HEIGHT = TERMINAL_HEIGHT.DEFAULT - -/** - * Column width constants - numeric values for calculations - */ -const BLOCK_COLUMN_WIDTH_PX = 240 const MIN_OUTPUT_PANEL_WIDTH_PX = OUTPUT_PANEL_WIDTH.MIN /** - * Column width constants - Tailwind classes for styling + * Block row component for displaying actual block entries */ -const COLUMN_WIDTHS = { - BLOCK: 'w-[240px]', - STATUS: 'w-[120px]', - DURATION: 'w-[120px]', - RUN_ID: 'w-[120px]', - TIMESTAMP: 'w-[120px]', - OUTPUT_PANEL: 'w-[400px]', -} as const +const BlockRow = memo(function BlockRow({ + entry, + isSelected, + onSelect, +}: { + 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) -/** - * 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' + return ( +
{ + e.stopPropagation() + onSelect(entry) + }} + > +
+
+ {BlockIcon && } +
+ + {entry.blockName} + +
+ + + +
+ ) +}) /** - * 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 + * Iteration node component - shows iteration header with nested blocks */ -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 - } +const IterationNodeRow = memo(function IterationNodeRow({ + node, + selectedEntryId, + onSelectEntry, + isExpanded, + onToggle, +}: { + node: EntryNode + selectedEntryId: string | null + onSelectEntry: (entry: ConsoleEntry) => void + isExpanded: boolean + onToggle: () => void +}) { + const { entry, children, iterationInfo } = node + const hasError = Boolean(entry.error) || children.some((c) => c.entry.error) + const hasChildren = children.length > 0 + const hasRunningChild = children.some((c) => c.entry.isRunning) + const hasCanceledChild = children.some((c) => c.entry.isCanceled) && !hasRunningChild + + const iterationLabel = iterationInfo + ? `Iteration ${iterationInfo.current}${iterationInfo.total !== undefined ? ` / ${iterationInfo.total}` : ''}` + : entry.blockName - return null -} + return ( +
+ {/* Iteration Header */} +
{ + e.stopPropagation() + onToggle() + }} + > +
+ + {iterationLabel} + + {hasChildren && ( + + )} +
+ + + +
+ + {/* Nested Blocks */} + {isExpanded && hasChildren && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ) +}) /** - * Formats duration from milliseconds to readable format + * Subflow node component - shows subflow header with nested iterations */ -const formatDuration = (ms?: number): string => { - if (ms === undefined || ms === null) return '-' - if (ms < 1000) return `${ms}ms` - return `${(ms / 1000).toFixed(2)}s` -} +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 -/** - * 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' } -} + const displayName = + entry.blockType === 'loop' + ? 'Loop' + : entry.blockType === 'parallel' + ? 'Parallel' + : entry.blockName -/** - * Reusable column header component with optional filter button - */ -const ColumnHeader = ({ - label, - width, - filterButton, -}: { - label: string - width: string - filterButton?: React.ReactNode -}) => ( -
- {label} - {filterButton &&
{filterButton}
} -
-) + return ( +
+ {/* Subflow Header */} +
{ + e.stopPropagation() + onToggleNode(nodeId) + }} + > +
+
+ {BlockIcon && } +
+ + {displayName} + + {hasChildren && ( + + )} +
+ + + +
+ + {/* Nested Iterations */} + {isExpanded && hasChildren && ( +
+ {children.map((iterNode) => ( + onToggleNode(iterNode.entry.id)} + /> + ))} +
+ )} +
+ ) +}) /** - * Reusable toggle button component + * Entry node component - dispatches to appropriate component based on node type */ -const ToggleButton = ({ - isExpanded, - onClick, +const EntryNodeRow = memo(function EntryNodeRow({ + node, + selectedEntryId, + onSelectEntry, + expandedNodes, + onToggleNode, }: { - isExpanded: boolean - onClick: (e: React.MouseEvent) => void -}) => ( - -) + node: EntryNode + selectedEntryId: string | null + onSelectEntry: (entry: ConsoleEntry) => void + expandedNodes: Set + onToggleNode: (nodeId: string) => void +}) { + const { nodeType } = node + + if (nodeType === 'subflow') { + return ( + + ) + } -/** - * Truncates execution ID for display as run ID - */ -const formatRunId = (executionId?: string): string => { - if (!executionId) return '-' - return executionId.slice(0, 8) -} + if (nodeType === 'iteration') { + return ( + onToggleNode(node.entry.id)} + /> + ) + } -/** - * 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 + // Regular block + return ( + + ) +}) /** - * Gets color for a run ID from the precomputed color map. + * Status badge component for execution rows */ -const getRunIdColor = (executionId: string | undefined, colorMap: Map) => { - if (!executionId) return null - return colorMap.get(executionId) ?? null -} +const StatusBadge = memo(function StatusBadge({ + hasError, + isRunning, + isCanceled, +}: { + hasError: boolean + isRunning: boolean + isCanceled: boolean +}) { + if (isRunning) { + return ( + + Running + + ) + } + if (isCanceled) { + return ( + + canceled + + ) + } + return ( + + {hasError ? 'error' : 'info'} + + ) +}) /** - * 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 + * Execution row component with expand/collapse */ -const isEventFromEditableElement = (e: KeyboardEvent): boolean => { - const target = e.target as HTMLElement | null - if (!target) return false - - const isEditable = (el: HTMLElement | null): boolean => { - if (!el) return false - if (el instanceof HTMLInputElement) return true - if (el instanceof HTMLTextAreaElement) return true - if ((el as HTMLElement).isContentEditable) return true - const role = el.getAttribute('role') - if (role === 'textbox' || role === 'combobox') return true - return false - } +const ExecutionRow = memo(function ExecutionRow({ + group, + isExpanded, + onToggle, + selectedEntryId, + onSelectEntry, + expandedNodes, + onToggleNode, +}: { + group: ExecutionGroup + isExpanded: boolean + onToggle: () => void + selectedEntryId: string | null + onSelectEntry: (entry: ConsoleEntry) => void + expandedNodes: Set + onToggleNode: (nodeId: string) => void +}) { + const hasError = group.status === 'error' + const hasRunningEntry = group.entries.some((entry) => entry.isRunning) + const hasCanceledEntry = group.entries.some((entry) => entry.isCanceled) && !hasRunningEntry - let el: HTMLElement | null = target - while (el) { - if (isEditable(el)) return true - el = el.parentElement - } - return false -} + return ( +
+ {/* Execution header */} +
+
+ + Run #{formatRunId(group.executionId)} + + + +
+ + + +
+ + {/* Expanded content - Tree structure */} + {isExpanded && ( +
+
+ {group.entryTree.map((node) => ( + + ))} +
+
+ )} +
+ ) +}) /** * Terminal component with resizable height that persists across page refreshes. - * - * Uses a CSS-based approach to prevent hydration mismatches: - * 1. Height is controlled by CSS variable (--terminal-height) - * 2. Blocking script in layout.tsx sets CSS variable before React hydrates - * 3. Store updates CSS variable when height changes - * - * This ensures server and client render identical HTML, preventing hydration errors. - * - * @returns Terminal at the bottom of the workflow */ export const Terminal = memo(function Terminal() { const terminalRef = useRef(null) + const logsContainerRef = useRef(null) const prevEntriesLengthRef = useRef(0) const prevWorkflowEntriesLengthRef = useRef(0) const hasInitializedEntriesRef = useRef(false) const isTerminalFocusedRef = useRef(false) const lastExpandedHeightRef = useRef(DEFAULT_EXPANDED_HEIGHT) + + // Store refs for keyboard handler to avoid stale closures + const selectedEntryRef = useRef(null) + const navigableEntriesRef = useRef([]) + const showInputRef = useRef(false) + const hasInputDataRef = useRef(false) + const isExpandedRef = useRef(false) + const setTerminalHeight = useTerminalStore((state) => state.setTerminalHeight) const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth) const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth) @@ -258,28 +549,31 @@ export const Terminal = memo(function Terminal() { const structuredView = useTerminalStore((state) => state.structuredView) const setStructuredView = useTerminalStore((state) => state.setStructuredView) const setHasHydrated = useTerminalStore((state) => state.setHasHydrated) - const isExpanded = useTerminalStore((state) => state.terminalHeight > NEAR_MIN_THRESHOLD) + const isExpanded = useTerminalStore( + (state) => state.terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD + ) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated) - const workflowEntriesSelector = useCallback( - (state: { entries: ConsoleEntry[] }) => - state.entries.filter((entry) => entry.workflowId === activeWorkflowId), - [activeWorkflowId] - ) - const entriesFromStore = useTerminalConsoleStore(useShallow(workflowEntriesSelector)) - const entries = hasConsoleHydrated ? entriesFromStore : [] + + // Get all entries and filter in useMemo to avoid new array on every store update + const allStoreEntries = useTerminalConsoleStore((state) => state.entries) + const entries = useMemo(() => { + if (!hasConsoleHydrated) return [] + return allStoreEntries.filter((entry) => entry.workflowId === activeWorkflowId) + }, [allStoreEntries, activeWorkflowId, hasConsoleHydrated]) + const clearWorkflowConsole = useTerminalConsoleStore((state) => state.clearWorkflowConsole) const exportConsoleCSV = useTerminalConsoleStore((state) => state.exportConsoleCSV) + const [selectedEntry, setSelectedEntry] = useState(null) + const [expandedExecutions, setExpandedExecutions] = useState>(new Set()) + const [expandedNodes, setExpandedNodes] = useState>(new Set()) const [isToggling, setIsToggling] = useState(false) const [showCopySuccess, setShowCopySuccess] = useState(false) const [showInput, setShowInput] = useState(false) const [autoSelectEnabled, setAutoSelectEnabled] = useState(true) - const [blockFilterOpen, setBlockFilterOpen] = useState(false) - const [statusFilterOpen, setStatusFilterOpen] = useState(false) - const [runIdFilterOpen, setRunIdFilterOpen] = useState(false) + const [filtersOpen, setFiltersOpen] = useState(false) const [mainOptionsOpen, setMainOptionsOpen] = useState(false) - const [outputOptionsOpen, setOutputOptionsOpen] = useState(false) const [isTrainingEnvEnabled, setIsTrainingEnvEnabled] = useState(false) const showTrainingControls = useShowTrainingControls() @@ -302,22 +596,15 @@ export const Terminal = memo(function Terminal() { hasActiveFilters, } = useTerminalFilters() - const [contextMenuEntry, setContextMenuEntry] = useState(null) - const { isOpen: isLogRowMenuOpen, position: logRowMenuPosition, menuRef: logRowMenuRef, - handleContextMenu: handleLogRowContextMenu, closeMenu: closeLogRowMenu, } = useContextMenu() /** - * Expands the terminal to its last meaningful height, with safeguards: - * - Never expands below {@link DEFAULT_EXPANDED_HEIGHT}. - * - Never exceeds 70% of the viewport height. - * - * Uses ref for lastExpandedHeight to avoid re-renders during resize. + * Expands the terminal to its last meaningful height */ const expandToLastHeight = useCallback(() => { setIsToggling(true) @@ -339,6 +626,26 @@ export const Terminal = memo(function Terminal() { return filterEntries(allWorkflowEntries) }, [allWorkflowEntries, filterEntries]) + /** + * Group filtered entries by execution + */ + const executionGroups = useMemo(() => { + return groupEntriesByExecution(filteredEntries) + }, [filteredEntries]) + + /** + * Navigable block entries for keyboard navigation. + * Only includes actual block outputs (not subflows/iterations/headers). + * Includes parent node IDs for auto-expanding when navigating. + */ + const navigableEntries = useMemo(() => { + const result: NavigableBlockEntry[] = [] + for (const group of executionGroups) { + result.push(...flattenBlockEntriesOnly(group.entryTree, group.executionId)) + } + return result + }, [executionGroups]) + /** * Get unique blocks (by ID) from all workflow entries */ @@ -370,15 +677,7 @@ export const Terminal = memo(function Terminal() { }, [allWorkflowEntries]) /** - * Check if there are any entries with status information (error or success) - */ - const hasStatusEntries = useMemo(() => { - return allWorkflowEntries.some((entry) => entry.error || entry.success !== undefined) - }, [allWorkflowEntries]) - - /** - * Track color offset - increments when old executions are trimmed - * so remaining executions keep their colors. + * Track color offset for run IDs */ const colorStateRef = useRef<{ executionIds: string[]; offset: number }>({ executionIds: [], @@ -386,9 +685,7 @@ export const Terminal = memo(function Terminal() { }) /** - * Compute colors for each execution ID using sequential assignment. - * Colors cycle through RUN_ID_COLORS based on position + offset. - * When old executions are trimmed, offset increments to preserve colors. + * Compute colors for each execution ID */ const executionColorMap = useMemo(() => { const currentIds: string[] = [] @@ -459,6 +756,15 @@ export const Terminal = memo(function Terminal() { return JSON.stringify(outputData, null, 2) }, [outputData]) + // Keep refs in sync for keyboard handler + useEffect(() => { + selectedEntryRef.current = selectedEntry + navigableEntriesRef.current = navigableEntries + showInputRef.current = showInput + hasInputDataRef.current = hasInputData + isExpandedRef.current = isExpanded + }, [selectedEntry, navigableEntries, showInput, hasInputData, isExpanded]) + /** * Reset entry tracking when switching workflows to ensure auto-open * works correctly for each workflow independently. @@ -509,17 +815,97 @@ export const Terminal = memo(function Terminal() { ]) /** - * Handle row click - toggle if clicking same entry - * Disables auto-selection when user manually selects, re-enables when deselecting - * Also focuses the terminal to enable keyboard navigation + * Auto-expand newest execution, subflows, and iterations when new entries arrive. + * This always runs regardless of autoSelectEnabled - new runs should always be visible. + */ + useEffect(() => { + if (executionGroups.length === 0) return + + const newestExec = executionGroups[0] + + // Always expand the newest execution group + setExpandedExecutions((prev) => { + if (prev.has(newestExec.executionId)) return prev + const next = new Set(prev) + next.add(newestExec.executionId) + return next + }) + + // Collect all node IDs that should be expanded (subflows and their iterations) + const nodeIdsToExpand: string[] = [] + for (const node of newestExec.entryTree) { + if (node.nodeType === 'subflow' && node.children.length > 0) { + nodeIdsToExpand.push(node.entry.id) + // Also expand all iteration children + for (const iterNode of node.children) { + if (iterNode.nodeType === 'iteration') { + nodeIdsToExpand.push(iterNode.entry.id) + } + } + } + } + + if (nodeIdsToExpand.length > 0) { + setExpandedNodes((prev) => { + const hasAll = nodeIdsToExpand.every((id) => prev.has(id)) + if (hasAll) return prev + const next = new Set(prev) + nodeIdsToExpand.forEach((id) => next.add(id)) + return next + }) + } + }, [executionGroups]) + + /** + * Focus the terminal for keyboard navigation */ - const handleRowClick = useCallback((entry: ConsoleEntry) => { - // Focus the terminal to enable keyboard navigation + const focusTerminal = useCallback(() => { terminalRef.current?.focus() - setSelectedEntry((prev) => { - const isDeselecting = prev?.id === entry.id - setAutoSelectEnabled(isDeselecting) - return isDeselecting ? null : entry + isTerminalFocusedRef.current = true + }, []) + + /** + * Handle entry selection + */ + const handleSelectEntry = useCallback( + (entry: ConsoleEntry) => { + focusTerminal() + setSelectedEntry((prev) => { + const isDeselecting = prev?.id === entry.id + setAutoSelectEnabled(isDeselecting) + return isDeselecting ? null : entry + }) + }, + [focusTerminal] + ) + + /** + * Toggle execution expansion + */ + const handleToggleExecution = useCallback((executionId: string) => { + setExpandedExecutions((prev) => { + const next = new Set(prev) + if (next.has(executionId)) { + next.delete(executionId) + } else { + next.add(executionId) + } + return next + }) + }, []) + + /** + * Toggle subflow node expansion + */ + const handleToggleNode = useCallback((nodeId: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev) + if (next.has(nodeId)) { + next.delete(nodeId) + } else { + next.add(nodeId) + } + return next }) }, []) @@ -536,44 +922,29 @@ export const Terminal = memo(function Terminal() { setIsToggling(false) }, []) - /** - * Handle terminal focus - enables keyboard navigation - */ const handleTerminalFocus = useCallback(() => { isTerminalFocusedRef.current = true }, []) - /** - * Handle terminal blur - disables keyboard navigation - */ const handleTerminalBlur = useCallback((e: React.FocusEvent) => { - // Only blur if focus is moving outside the terminal if (!terminalRef.current?.contains(e.relatedTarget as Node)) { isTerminalFocusedRef.current = false } }, []) - /** - * Handle copy output to clipboard - */ const handleCopy = useCallback(() => { if (!selectedEntry) return - const textToCopy = shouldShowCodeDisplay ? selectedEntry.input.code : outputDataStringified - navigator.clipboard.writeText(textToCopy) setShowCopySuccess(true) }, [selectedEntry, outputDataStringified, shouldShowCodeDisplay]) - /** - * Clears the console for the active workflow. - * - * Extracted so it can be reused both by click handlers and global commands. - */ const clearCurrentWorkflowConsole = useCallback(() => { if (activeWorkflowId) { clearWorkflowConsole(activeWorkflowId) setSelectedEntry(null) + setExpandedExecutions(new Set()) + setExpandedNodes(new Set()) } }, [activeWorkflowId, clearWorkflowConsole]) @@ -595,14 +966,6 @@ export const Terminal = memo(function Terminal() { [activeWorkflowId, exportConsoleCSV] ) - const handleRowContextMenu = useCallback( - (e: React.MouseEvent, entry: ConsoleEntry) => { - setContextMenuEntry(entry) - handleLogRowContextMenu(e) - }, - [handleLogRowContextMenu] - ) - const handleFilterByBlock = useCallback( (blockId: string) => { toggleBlock(blockId) @@ -664,13 +1027,6 @@ export const Terminal = memo(function Terminal() { const shouldShowTrainingButton = isTrainingEnvEnabled && showTrainingControls - /** - * Register global keyboard shortcuts for the terminal: - * - Mod+D: Clear terminal console for the active workflow - * - * The command is disabled in editable contexts so it does not interfere - * with typing inside inputs, textareas, or editors. - */ useRegisterGlobalCommands(() => createCommands([ { @@ -685,62 +1041,40 @@ export const Terminal = memo(function Terminal() { ]) ) - /** - * Mark hydration as complete on mount - */ useEffect(() => { setHasHydrated(true) }, [setHasHydrated]) - /** - * Sync lastExpandedHeightRef with store value on mount. - * Uses subscription to keep ref updated without causing re-renders. - */ useEffect(() => { - // Initialize with current value lastExpandedHeightRef.current = useTerminalStore.getState().lastExpandedHeight - const unsub = useTerminalStore.subscribe((state) => { lastExpandedHeightRef.current = state.lastExpandedHeight }) return unsub }, []) - /** - * Check environment variables on mount - */ useEffect(() => { setIsTrainingEnvEnabled(isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))) setIsPlaygroundEnabled(isTruthy(getEnv('NEXT_PUBLIC_ENABLE_PLAYGROUND'))) }, []) - /** - * Adjust showInput when selected entry changes - * Stay on input view if the new entry has input data - */ useEffect(() => { if (!selectedEntry) { setShowInput(false) return } - - // If we're viewing input but the new entry has no input, switch to output if (showInput) { const newHasInput = selectedEntry.input && (typeof selectedEntry.input === 'object' ? Object.keys(selectedEntry.input).length > 0 : true) - if (!newHasInput) { setShowInput(false) } } }, [selectedEntry, showInput]) - /** - * Reset copy success state after 2 seconds - */ useEffect(() => { if (showCopySuccess) { const timer = setTimeout(() => { @@ -751,132 +1085,235 @@ export const Terminal = memo(function Terminal() { }, [showCopySuccess]) /** - * Auto-select the latest entry when new logs arrive - * Re-enables auto-selection when all entries are cleared - * Only auto-selects when NEW entries are added (length increases) + * Scroll the logs container to the bottom. + */ + const scrollToBottom = useCallback(() => { + requestAnimationFrame(() => { + const container = logsContainerRef.current + if (!container) return + container.scrollTop = container.scrollHeight + }) + }, []) + + /** + * Scroll an entry into view (for keyboard navigation). + */ + const scrollEntryIntoView = useCallback((entryId: string) => { + requestAnimationFrame(() => { + const container = logsContainerRef.current + if (!container) return + const el = container.querySelector(`[data-entry-id="${entryId}"]`) + if (el) { + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + } + }) + }, []) + + /** + * Auto-select the last entry (bottom of the list) when new logs arrive. */ useEffect(() => { - if (filteredEntries.length === 0) { - // Re-enable auto-selection when console is cleared + if (executionGroups.length === 0 || navigableEntries.length === 0) { setAutoSelectEnabled(true) setSelectedEntry(null) prevEntriesLengthRef.current = 0 return } - // Auto-select the latest entry only when a NEW entry is added (length increased) - if (autoSelectEnabled && filteredEntries.length > prevEntriesLengthRef.current) { - const latestEntry = filteredEntries[0] - setSelectedEntry(latestEntry) - } + if (autoSelectEnabled && navigableEntries.length > prevEntriesLengthRef.current) { + // Get the last entry from the newest execution (it's at the bottom of the list) + const newestExecutionId = executionGroups[0].executionId + let lastNavEntry: NavigableBlockEntry | null = null - prevEntriesLengthRef.current = filteredEntries.length - }, [filteredEntries, autoSelectEnabled]) + for (const navEntry of navigableEntries) { + if (navEntry.executionId === newestExecutionId) { + lastNavEntry = navEntry + } else { + break + } + } - /** - * Handle keyboard navigation through logs - * Disables auto-selection when user manually navigates - * Only active when the terminal is focused - */ - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle navigation when terminal is focused - if (!isTerminalFocusedRef.current) return - if (isEventFromEditableElement(e)) return - const activeElement = document.activeElement as HTMLElement | null - const toolbarRoot = document.querySelector( - '[data-toolbar-root][data-search-active=\"true\"]' - ) as HTMLElement | null - if (toolbarRoot && activeElement && toolbarRoot.contains(activeElement)) { + if (!lastNavEntry) { + prevEntriesLengthRef.current = navigableEntries.length return } - if (!selectedEntry || filteredEntries.length === 0) return + setSelectedEntry(lastNavEntry.entry) + focusTerminal() + + // Expand execution and parent nodes + setExpandedExecutions((prev) => { + if (prev.has(lastNavEntry.executionId)) return prev + const next = new Set(prev) + next.add(lastNavEntry.executionId) + return next + }) + if (lastNavEntry.parentNodeIds.length > 0) { + setExpandedNodes((prev) => { + const hasAll = lastNavEntry.parentNodeIds.every((id) => prev.has(id)) + if (hasAll) return prev + const next = new Set(prev) + lastNavEntry.parentNodeIds.forEach((id) => next.add(id)) + return next + }) + } - if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return + scrollToBottom() + } - e.preventDefault() + prevEntriesLengthRef.current = navigableEntries.length + }, [executionGroups, navigableEntries, autoSelectEnabled, focusTerminal, scrollToBottom]) - const currentIndex = filteredEntries.findIndex((entry) => entry.id === selectedEntry.id) - if (currentIndex === -1) return + /** + * 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 - 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]) + 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) } } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEntry, filteredEntries]) + }, [filteredEntries, selectedEntry]) /** - * Handle keyboard navigation for input/output toggle - * Left arrow shows output, right arrow shows input - * Only active when the terminal is focused + * Clear filters when there are no logs */ 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 (allWorkflowEntries.length === 0 && hasActiveFilters) { + clearFilters() + } + }, [allWorkflowEntries.length, hasActiveFilters, clearFilters]) - if (!isExpanded) { - expandToLastHeight() + /** + * Navigate to a block entry and auto-expand its parents + */ + const navigateToEntry = useCallback( + (navEntry: NavigableBlockEntry) => { + setAutoSelectEnabled(false) + setSelectedEntry(navEntry.entry) + + // Auto-expand the execution group + setExpandedExecutions((prev) => { + if (prev.has(navEntry.executionId)) return prev + const next = new Set(prev) + next.add(navEntry.executionId) + return next + }) + + // Auto-expand parent nodes (subflows, iterations) + if (navEntry.parentNodeIds.length > 0) { + setExpandedNodes((prev) => { + const hasAll = navEntry.parentNodeIds.every((id) => prev.has(id)) + if (hasAll) return prev + const next = new Set(prev) + navEntry.parentNodeIds.forEach((id) => next.add(id)) + return next + }) } - if (e.key === 'ArrowLeft') { - if (showInput) { - setShowInput(false) - } - } else if (e.key === 'ArrowRight') { - if (!showInput && hasInputData) { - setShowInput(true) - } - } - } + // Keep terminal focused for continued navigation + focusTerminal() - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded]) + // Scroll entry into view if needed + scrollEntryIntoView(navEntry.entry.id) + }, + [focusTerminal, scrollEntryIntoView] + ) /** - * Handle Escape to unselect entry (search close is handled by OutputPanel internally) - * Check if the focused element is in the search overlay to avoid conflicting with search close. + * Consolidated keyboard handler for all terminal navigation */ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Escape' || !selectedEntry) return + // Common guards + if (isEventFromEditableElement(e)) 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) + const currentEntry = selectedEntryRef.current + const entries = navigableEntriesRef.current + + // Escape to unselect + if (e.key === 'Escape') { + if (currentEntry) { + e.preventDefault() + setSelectedEntry(null) + setAutoSelectEnabled(true) + } + 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) + } + } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEntry]) + }, [expandToLastHeight, navigateToEntry]) /** - * Adjust output panel width when sidebar or panel width changes. - * Ensures output panel doesn't exceed maximum allowed width. + * Adjust output panel width on resize */ useEffect(() => { const handleResize = () => { @@ -890,7 +1327,7 @@ export const Terminal = memo(function Terminal() { ) const terminalWidth = window.innerWidth - sidebarWidth - panelWidth - const maxWidth = terminalWidth - BLOCK_COLUMN_WIDTH_PX + const maxWidth = terminalWidth - TERMINAL_CONFIG.BLOCK_COLUMN_WIDTH_PX if (outputPanelWidth > maxWidth && maxWidth >= MIN_OUTPUT_PANEL_WIDTH_PX) { setOutputPanelWidth(Math.max(maxWidth, MIN_OUTPUT_PANEL_WIDTH_PX)) @@ -940,215 +1377,64 @@ export const Terminal = memo(function Terminal() { aria-label='Terminal' >
- {/* Left Section - Logs Table */} + {/* Left Section - Logs */}
{/* Header */}
- {uniqueBlocks.length > 0 ? ( -
- - - - - e.stopPropagation()} - minWidth={120} - maxWidth={200} - > - - {uniqueBlocks.map((block, index) => { - const BlockIcon = getBlockIcon(block.blockType) - const isSelected = filters.blockIds.has(block.blockId) - - 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 ? ( -
- -
- ) : ( - - )} + {/* Left side - Logs label */} + Logs + + {/* Right side - Filters and icons */} {!selectedEntry && ( -
+
+ {/* Unified filter popover */} + {allWorkflowEntries.length > 0 && ( + + )} + + {/* Sort toggle */} + {allWorkflowEntries.length > 0 && ( + + + + + + Sort by time + + + )} + {isPlaygroundEnabled && ( @@ -1167,6 +1453,7 @@ export const Terminal = memo(function Terminal() { )} + {shouldShowTrainingButton && ( @@ -1191,6 +1478,7 @@ export const Terminal = memo(function Terminal() { )} + {hasActiveFilters && ( @@ -1211,6 +1499,7 @@ export const Terminal = memo(function Terminal() { )} + {filteredEntries.length > 0 && ( <> @@ -1245,6 +1534,7 @@ export const Terminal = memo(function Terminal() { )} +
- {/* Rows */} -
- {filteredEntries.length === 0 ? ( + {/* Execution list */} +
+ {executionGroups.length === 0 ? (
No logs yet
) : ( - filteredEntries.map((entry) => { - const statusInfo = getStatusInfo(entry.success, entry.error) - const isSelected = selectedEntry?.id === entry.id - const BlockIcon = getBlockIcon(entry.blockType) - const runIdColor = getRunIdColor(entry.executionId, executionColorMap) - - return ( -
handleRowClick(entry)} - onContextMenu={(e) => handleRowContextMenu(e, entry)} - > - {/* Block */} -
- {BlockIcon && ( - - )} - {entry.blockName} -
- - {/* Status */} -
- {statusInfo ? ( - - {statusInfo.label} - - ) : ( - - - )} -
- - {/* Run ID */} - - {formatRunId(entry.executionId)} - - - {/* Duration */} - - {formatDuration(entry.durationMs)} - - - {/* Timestamp */} - - {formatTimeWithSeconds(new Date(entry.timestamp))} - -
- ) - }) + executionGroups.map((group) => ( + handleToggleExecution(group.executionId)} + selectedEntryId={selectedEntry?.id || null} + onSelectEntry={handleSelectEntry} + expandedNodes={expandedNodes} + onToggleNode={handleToggleNode} + /> + )) )}
@@ -1390,7 +1608,6 @@ export const Terminal = memo(function Terminal() { {selectedEntry && ( )}
@@ -1432,7 +1648,7 @@ export const Terminal = memo(function Terminal() { position={logRowMenuPosition} menuRef={logRowMenuRef} onClose={closeLogRowMenu} - entry={contextMenuEntry} + entry={selectedEntry} filters={filters} onFilterByBlock={handleFilterByBlock} onFilterByStatus={handleFilterByStatus} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx new file mode 100644 index 0000000000..35c8607a48 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx @@ -0,0 +1,111 @@ +'use client' + +import { memo } from 'react' +import { Badge } from '@/components/emcn' + +/** + * Terminal filter configuration state + */ +export interface TerminalFilters { + blockIds: Set + statuses: Set<'error' | 'info'> + runIds: Set +} + +/** + * Context menu position for positioning floating menus + */ +export interface ContextMenuPosition { + x: number + y: number +} + +/** + * Sort field options for terminal entries + */ +export type SortField = 'timestamp' + +/** + * Sort direction options + */ +export type SortDirection = 'asc' | 'desc' + +/** + * Sort configuration for terminal entries + */ +export interface SortConfig { + field: SortField + direction: SortDirection +} + +/** + * Status type for console entries + */ +export type EntryStatus = 'error' | 'info' + +/** + * Block information for filters + */ +export interface BlockInfo { + blockId: string + blockName: string + blockType: string +} + +/** + * Common row styling classes for terminal components + */ +export const ROW_STYLES = { + base: 'group flex cursor-pointer items-center justify-between gap-[8px] rounded-[8px] px-[6px]', + selected: 'bg-[var(--surface-6)] dark:bg-[var(--surface-5)]', + hover: 'hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]', + nested: + 'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]', + iconButton: '!p-1.5 -m-1.5', +} as const + +/** + * Common badge styling for status badges + */ +export const BADGE_STYLES = { + base: 'rounded-[4px] px-[4px] py-[0px] text-[11px]', + mono: 'rounded-[4px] px-[4px] py-[0px] font-mono text-[11px]', +} as const + +/** + * Running badge component - displays a consistent "Running" indicator + */ +export const RunningBadge = memo(function RunningBadge() { + return ( + + Running + + ) +}) + +/** + * Props for StatusDisplay component + */ +export interface StatusDisplayProps { + isRunning: boolean + isCanceled: boolean + formattedDuration: string +} + +/** + * Reusable status display for terminal rows. + * Shows Running badge, 'canceled' text, or formatted duration. + */ +export const StatusDisplay = memo(function StatusDisplay({ + isRunning, + isCanceled, + formattedDuration, +}: StatusDisplayProps) { + if (isRunning) { + return + } + if (isCanceled) { + return <>canceled + } + return <>{formattedDuration} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts new file mode 100644 index 0000000000..c0a9dfca29 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts @@ -0,0 +1,488 @@ +'use client' + +import type React from 'react' +import { RepeatIcon, SplitIcon } from 'lucide-react' +import { getBlock } from '@/blocks' +import type { ConsoleEntry } from '@/stores/terminal' + +/** + * Subflow colors matching the subflow tool configs + */ +const SUBFLOW_COLORS = { + loop: '#2FB3FF', + parallel: '#FEE12B', +} as const + +/** + * Run ID color palette for visual distinction between executions + */ +export const RUN_ID_COLORS = [ + '#4ADE80', // Green + '#F472B6', // Pink + '#60C5FF', // Blue + '#FF8533', // Orange + '#C084FC', // Purple + '#EAB308', // Yellow + '#2DD4BF', // Teal + '#FB7185', // Rose +] as const + +/** + * Retrieves the icon component for a given block type + */ +export function getBlockIcon( + blockType: string +): React.ComponentType<{ className?: string }> | null { + const blockConfig = getBlock(blockType) + + if (blockConfig?.icon) { + return blockConfig.icon + } + + if (blockType === 'loop') { + return RepeatIcon + } + + if (blockType === 'parallel') { + return SplitIcon + } + + return null +} + +/** + * Gets the background color for a block type + */ +export function getBlockColor(blockType: string): string { + const blockConfig = getBlock(blockType) + if (blockConfig?.bgColor) { + return blockConfig.bgColor + } + // Use proper subflow colors matching the toolbar configs + if (blockType === 'loop') { + return SUBFLOW_COLORS.loop + } + if (blockType === 'parallel') { + return SUBFLOW_COLORS.parallel + } + return '#6b7280' +} + +/** + * Formats duration from milliseconds to readable format + */ +export function formatDuration(ms?: number): string { + if (ms === undefined || ms === null) return '-' + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +/** + * Truncates execution ID for display as run ID + */ +export function formatRunId(executionId?: string): string { + if (!executionId) return '-' + return executionId.slice(0, 8) +} + +/** + * Gets color for a run ID from the precomputed color map + */ +export function getRunIdColor( + executionId: string | undefined, + colorMap: Map +): string | null { + if (!executionId) return null + return colorMap.get(executionId) ?? null +} + +/** + * Determines if a keyboard event originated from a text-editable element + */ +export function isEventFromEditableElement(e: KeyboardEvent): boolean { + const target = e.target as HTMLElement | null + if (!target) return false + + const isEditable = (el: HTMLElement | null): boolean => { + if (!el) return false + if (el instanceof HTMLInputElement) return true + if (el instanceof HTMLTextAreaElement) return true + if ((el as HTMLElement).isContentEditable) return true + const role = el.getAttribute('role') + if (role === 'textbox' || role === 'combobox') return true + return false + } + + let el: HTMLElement | null = target + while (el) { + if (isEditable(el)) return true + el = el.parentElement + } + return false +} + +/** + * Checks if a block type is a subflow (loop or parallel) + */ +export function isSubflowBlockType(blockType: string): boolean { + const lower = blockType?.toLowerCase() || '' + return lower === 'loop' || lower === 'parallel' +} + +/** + * Node type for the tree structure + */ +export type EntryNodeType = 'block' | 'subflow' | 'iteration' + +/** + * Entry node for tree structure - represents a block, subflow, or iteration + */ +export interface EntryNode { + /** The console entry (for blocks) or synthetic entry (for subflows/iterations) */ + entry: ConsoleEntry + /** Child nodes */ + children: EntryNode[] + /** Node type */ + nodeType: EntryNodeType + /** Iteration info for iteration nodes */ + iterationInfo?: { + current: number + total?: number + } +} + +/** + * Execution group interface for grouping entries by execution + */ +export interface ExecutionGroup { + executionId: string + startTime: string + endTime: string + startTimeMs: number + endTimeMs: number + duration: number + status: 'success' | 'error' + /** Flat list of entries (legacy, kept for filters) */ + entries: ConsoleEntry[] + /** Tree structure of entry nodes for nested display */ + entryTree: EntryNode[] +} + +/** + * Iteration group for grouping blocks within the same iteration + */ +interface IterationGroup { + iterationType: string + iterationCurrent: number + iterationTotal?: number + blocks: ConsoleEntry[] + startTimeMs: number +} + +/** + * Builds a tree structure from flat entries. + * Groups iteration entries by (iterationType, iterationCurrent), showing all blocks + * that executed within each iteration. + * Sorts by start time to ensure chronological order. + */ +function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { + // Separate regular blocks from iteration entries + const regularBlocks: ConsoleEntry[] = [] + const iterationEntries: ConsoleEntry[] = [] + + for (const entry of entries) { + if (entry.iterationType && entry.iterationCurrent !== undefined) { + iterationEntries.push(entry) + } else { + regularBlocks.push(entry) + } + } + + // Group iteration entries by (iterationType, iterationCurrent) + const iterationGroupsMap = new Map() + for (const entry of iterationEntries) { + const key = `${entry.iterationType}-${entry.iterationCurrent}` + let group = iterationGroupsMap.get(key) + const entryStartMs = new Date(entry.startedAt || entry.timestamp).getTime() + + if (!group) { + group = { + iterationType: entry.iterationType!, + iterationCurrent: entry.iterationCurrent!, + iterationTotal: entry.iterationTotal, + blocks: [], + startTimeMs: entryStartMs, + } + iterationGroupsMap.set(key, group) + } else { + // Update start time to earliest + if (entryStartMs < group.startTimeMs) { + group.startTimeMs = entryStartMs + } + // Update total if available + if (entry.iterationTotal !== undefined) { + group.iterationTotal = entry.iterationTotal + } + } + group.blocks.push(entry) + } + + // Sort blocks within each iteration by start time ascending (oldest first, top-down) + for (const group of iterationGroupsMap.values()) { + group.blocks.sort((a, b) => { + const aStart = new Date(a.startedAt || a.timestamp).getTime() + const bStart = new Date(b.startedAt || b.timestamp).getTime() + return aStart - bStart + }) + } + + // Group iterations by iterationType to create subflow parents + const subflowGroups = new Map() + for (const group of iterationGroupsMap.values()) { + const type = group.iterationType + let groups = subflowGroups.get(type) + if (!groups) { + groups = [] + subflowGroups.set(type, groups) + } + groups.push(group) + } + + // Sort iterations within each subflow by iteration number + for (const groups of subflowGroups.values()) { + groups.sort((a, b) => a.iterationCurrent - b.iterationCurrent) + } + + // Build subflow nodes with iteration children + const subflowNodes: EntryNode[] = [] + for (const [iterationType, iterationGroups] of subflowGroups.entries()) { + // Calculate subflow timing from all its iterations + const firstIteration = iterationGroups[0] + const allBlocks = iterationGroups.flatMap((g) => g.blocks) + const subflowStartMs = Math.min( + ...allBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime()) + ) + const subflowEndMs = Math.max( + ...allBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime()) + ) + const totalDuration = allBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0) + + // Create synthetic subflow parent entry + const syntheticSubflow: ConsoleEntry = { + id: `subflow-${iterationType}-${firstIteration.blocks[0]?.executionId || 'unknown'}`, + timestamp: new Date(subflowStartMs).toISOString(), + workflowId: firstIteration.blocks[0]?.workflowId || '', + blockId: `${iterationType}-container`, + blockName: iterationType.charAt(0).toUpperCase() + iterationType.slice(1), + blockType: iterationType, + executionId: firstIteration.blocks[0]?.executionId, + startedAt: new Date(subflowStartMs).toISOString(), + endedAt: new Date(subflowEndMs).toISOString(), + durationMs: totalDuration, + success: !allBlocks.some((b) => b.error), + } + + // Build iteration child nodes + const iterationNodes: EntryNode[] = iterationGroups.map((iterGroup) => { + // Create synthetic iteration entry + const iterBlocks = iterGroup.blocks + const iterStartMs = Math.min( + ...iterBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime()) + ) + const iterEndMs = Math.max( + ...iterBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime()) + ) + const iterDuration = iterBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0) + + const syntheticIteration: ConsoleEntry = { + id: `iteration-${iterationType}-${iterGroup.iterationCurrent}-${iterBlocks[0]?.executionId || 'unknown'}`, + timestamp: new Date(iterStartMs).toISOString(), + workflowId: iterBlocks[0]?.workflowId || '', + blockId: `iteration-${iterGroup.iterationCurrent}`, + blockName: `Iteration ${iterGroup.iterationCurrent}${iterGroup.iterationTotal !== undefined ? ` / ${iterGroup.iterationTotal}` : ''}`, + blockType: iterationType, + executionId: iterBlocks[0]?.executionId, + startedAt: new Date(iterStartMs).toISOString(), + endedAt: new Date(iterEndMs).toISOString(), + durationMs: iterDuration, + success: !iterBlocks.some((b) => b.error), + iterationCurrent: iterGroup.iterationCurrent, + iterationTotal: iterGroup.iterationTotal, + iterationType: iterationType as 'loop' | 'parallel', + } + + // Block nodes within this iteration + const blockNodes: EntryNode[] = iterBlocks.map((block) => ({ + entry: block, + children: [], + nodeType: 'block' as const, + })) + + return { + entry: syntheticIteration, + children: blockNodes, + nodeType: 'iteration' as const, + iterationInfo: { + current: iterGroup.iterationCurrent, + total: iterGroup.iterationTotal, + }, + } + }) + + subflowNodes.push({ + entry: syntheticSubflow, + children: iterationNodes, + nodeType: 'subflow' as const, + }) + } + + // Build nodes for regular blocks + const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({ + entry, + children: [], + nodeType: 'block' as const, + })) + + // Combine all nodes and sort by start time ascending (oldest first, top-down) + const allNodes = [...subflowNodes, ...regularNodes] + allNodes.sort((a, b) => { + const aStart = new Date(a.entry.startedAt || a.entry.timestamp).getTime() + const bStart = new Date(b.entry.startedAt || b.entry.timestamp).getTime() + return aStart - bStart + }) + + return allNodes +} + +/** + * Groups console entries by execution ID and builds a tree structure. + * Pre-computes timestamps for efficient sorting. + */ +export function groupEntriesByExecution(entries: ConsoleEntry[]): ExecutionGroup[] { + const groups = new Map< + string, + { meta: Omit; entries: ConsoleEntry[] } + >() + + for (const entry of entries) { + const execId = entry.executionId || entry.id + + const entryStartTime = entry.startedAt || entry.timestamp + const entryEndTime = entry.endedAt || entry.timestamp + const entryStartMs = new Date(entryStartTime).getTime() + const entryEndMs = new Date(entryEndTime).getTime() + + let group = groups.get(execId) + + if (!group) { + group = { + meta: { + executionId: execId, + startTime: entryStartTime, + endTime: entryEndTime, + startTimeMs: entryStartMs, + endTimeMs: entryEndMs, + duration: 0, + status: 'success', + entries: [], + }, + entries: [], + } + groups.set(execId, group) + } else { + // Update timing bounds + if (entryStartMs < group.meta.startTimeMs) { + group.meta.startTime = entryStartTime + group.meta.startTimeMs = entryStartMs + } + if (entryEndMs > group.meta.endTimeMs) { + group.meta.endTime = entryEndTime + group.meta.endTimeMs = entryEndMs + } + } + + // Check for errors + if (entry.error) { + group.meta.status = 'error' + } + + group.entries.push(entry) + } + + // Build tree structure for each group + const result: ExecutionGroup[] = [] + for (const group of groups.values()) { + group.meta.duration = group.meta.endTimeMs - group.meta.startTimeMs + group.meta.entries = group.entries + result.push({ + ...group.meta, + entryTree: buildEntryTree(group.entries), + }) + } + + // Sort by start time descending (newest first) + result.sort((a, b) => b.startTimeMs - a.startTimeMs) + + return result +} + +/** + * Flattens entry tree into display order for keyboard navigation + */ +export function flattenEntryTree(nodes: EntryNode[]): ConsoleEntry[] { + const result: ConsoleEntry[] = [] + for (const node of nodes) { + result.push(node.entry) + if (node.children.length > 0) { + result.push(...flattenEntryTree(node.children)) + } + } + return result +} + +/** + * Block entry with parent tracking for navigation + */ +export interface NavigableBlockEntry { + entry: ConsoleEntry + executionId: string + /** IDs of parent nodes (subflows, iterations) that contain this block */ + parentNodeIds: string[] +} + +/** + * Flattens entry tree to only include actual block entries (not subflows/iterations). + * Also tracks parent node IDs for auto-expanding when navigating. + */ +export function flattenBlockEntriesOnly( + nodes: EntryNode[], + executionId: string, + parentIds: string[] = [] +): NavigableBlockEntry[] { + const result: NavigableBlockEntry[] = [] + for (const node of nodes) { + if (node.nodeType === 'block') { + result.push({ + entry: node.entry, + executionId, + parentNodeIds: parentIds, + }) + } + if (node.children.length > 0) { + const newParentIds = node.nodeType !== 'block' ? [...parentIds, node.entry.id] : parentIds + result.push(...flattenBlockEntriesOnly(node.children, executionId, newParentIds)) + } + } + return result +} + +// BlockInfo is now in types.ts for shared use across terminal components + +/** + * Terminal height configuration constants + */ +export const TERMINAL_CONFIG = { + NEAR_MIN_THRESHOLD: 40, + BLOCK_COLUMN_WIDTH_PX: 240, + HEADER_TEXT_CLASS: 'font-medium text-[var(--text-tertiary)] text-[12px]', +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 1c0cbcc7a0..dea55acdc2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -81,7 +81,8 @@ export function useWorkflowExecution() { const queryClient = useQueryClient() const currentWorkflow = useCurrentWorkflow() const { activeWorkflowId, workflows } = useWorkflowRegistry() - const { toggleConsole, addConsole } = useTerminalConsoleStore() + const { toggleConsole, addConsole, updateConsole, cancelRunningEntries } = + useTerminalConsoleStore() const { getAllVariables } = useEnvironmentStore() const { getVariablesByWorkflowId, variables } = useVariablesStore() const { @@ -867,6 +868,8 @@ export function useWorkflowExecution() { if (activeWorkflowId) { logger.info('Using server-side executor') + const executionId = uuidv4() + let executionResult: ExecutionResult = { success: false, output: {}, @@ -910,6 +913,27 @@ export function useWorkflowExecution() { incomingEdges.forEach((edge) => { setEdgeRunStatus(edge.id, 'success') }) + + // Add entry to terminal immediately with isRunning=true + const startedAt = new Date().toISOString() + addConsole({ + input: {}, + output: undefined, + success: undefined, + durationMs: undefined, + startedAt, + endedAt: undefined, + workflowId: activeWorkflowId, + blockId: data.blockId, + executionId, + blockName: data.blockName || 'Unknown Block', + blockType: data.blockType || 'unknown', + isRunning: true, + // Pass through iteration context for subflow grouping + iterationCurrent: data.iterationCurrent, + iterationTotal: data.iterationTotal, + iterationType: data.iterationType, + }) }, onBlockCompleted: (data) => { @@ -940,24 +964,23 @@ export function useWorkflowExecution() { endedAt, }) - // Add to console - addConsole({ - input: data.input || {}, - output: data.output, - success: true, - durationMs: data.durationMs, - startedAt, - endedAt, - workflowId: activeWorkflowId, - blockId: data.blockId, - executionId: executionId || uuidv4(), - blockName: data.blockName || 'Unknown Block', - blockType: data.blockType || 'unknown', - // Pass through iteration context for console pills - iterationCurrent: data.iterationCurrent, - iterationTotal: data.iterationTotal, - iterationType: data.iterationType, - }) + // Update existing console entry (created in onBlockStarted) with completion data + updateConsole( + data.blockId, + { + input: data.input || {}, + replaceOutput: data.output, + success: true, + durationMs: data.durationMs, + endedAt, + isRunning: false, + // Pass through iteration context for subflow grouping + iterationCurrent: data.iterationCurrent, + iterationTotal: data.iterationTotal, + iterationType: data.iterationType, + }, + executionId + ) // Call onBlockComplete callback if provided if (onBlockComplete) { @@ -992,25 +1015,24 @@ export function useWorkflowExecution() { endedAt, }) - // Add error to console - addConsole({ - input: data.input || {}, - output: {}, - success: false, - error: data.error, - durationMs: data.durationMs, - startedAt, - endedAt, - workflowId: activeWorkflowId, - blockId: data.blockId, - executionId: executionId || uuidv4(), - blockName: data.blockName, - blockType: data.blockType, - // Pass through iteration context for console pills - iterationCurrent: data.iterationCurrent, - iterationTotal: data.iterationTotal, - iterationType: data.iterationType, - }) + // Update existing console entry (created in onBlockStarted) with error data + updateConsole( + data.blockId, + { + input: data.input || {}, + replaceOutput: {}, + success: false, + error: data.error, + durationMs: data.durationMs, + endedAt, + isRunning: false, + // Pass through iteration context for subflow grouping + iterationCurrent: data.iterationCurrent, + iterationTotal: data.iterationTotal, + iterationType: data.iterationType, + }, + executionId + ) }, onStreamChunk: (data) => { @@ -1089,7 +1111,7 @@ export function useWorkflowExecution() { endedAt: new Date().toISOString(), workflowId: activeWorkflowId, blockId: 'validation', - executionId: executionId || uuidv4(), + executionId, blockName: 'Workflow Validation', blockType: 'validation', }) @@ -1358,6 +1380,11 @@ export function useWorkflowExecution() { // Mark current chat execution as superseded so its cleanup won't affect new executions currentChatExecutionIdRef.current = null + // Mark all running entries as canceled in the terminal + if (activeWorkflowId) { + cancelRunningEntries(activeWorkflowId) + } + // Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx setIsExecuting(false) setIsDebugging(false) @@ -1374,6 +1401,8 @@ export function useWorkflowExecution() { setIsExecuting, setIsDebugging, setActiveBlocks, + activeWorkflowId, + cancelRunningEntries, ]) return { diff --git a/apps/sim/components/emcn/components/code/code.tsx b/apps/sim/components/emcn/components/code/code.tsx index 45ac92cae3..58250adc1c 100644 --- a/apps/sim/components/emcn/components/code/code.tsx +++ b/apps/sim/components/emcn/components/code/code.tsx @@ -949,6 +949,9 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({ [contentRef] ) + const hasCollapsibleContent = collapsibleLines.size > 0 + const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent + const rowProps = useMemo( () => ({ lines: visibleLines, @@ -957,7 +960,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({ gutterStyle, leftOffset: paddingLeft, wrapText, - showCollapseColumn, + showCollapseColumn: effectiveShowCollapseColumn, collapsibleLines, collapsedLines, onToggleCollapse: toggleCollapse, @@ -969,7 +972,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({ gutterStyle, paddingLeft, wrapText, - showCollapseColumn, + effectiveShowCollapseColumn, collapsibleLines, collapsedLines, toggleCollapse, @@ -1103,7 +1106,10 @@ function ViewerInner({ }, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex]) const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre' - const collapseColumnWidth = showCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0 + + const hasCollapsibleContent = collapsibleLines.size > 0 + const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent + const collapseColumnWidth = effectiveShowCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0 // Grid-based rendering for gutter alignment (works with wrap) if (showGutter) { @@ -1116,7 +1122,7 @@ function ViewerInner({ paddingTop: '8px', paddingBottom: '8px', display: 'grid', - gridTemplateColumns: showCollapseColumn + gridTemplateColumns: effectiveShowCollapseColumn ? `${gutterWidth}px ${collapseColumnWidth}px 1fr` : `${gutterWidth}px 1fr`, }} @@ -1134,7 +1140,7 @@ function ViewerInner({ > {lineNumber}
- {showCollapseColumn && ( + {effectiveShowCollapseColumn && (
{isCollapsible && ( ()( : update.input } + if (update.isRunning !== undefined) { + updatedEntry.isRunning = update.isRunning + } + + if (update.isCanceled !== undefined) { + updatedEntry.isCanceled = update.isCanceled + } + + if (update.iterationCurrent !== undefined) { + updatedEntry.iterationCurrent = update.iterationCurrent + } + + if (update.iterationTotal !== undefined) { + updatedEntry.iterationTotal = update.iterationTotal + } + + if (update.iterationType !== undefined) { + updatedEntry.iterationType = update.iterationType + } + return updatedEntry }) return { entries: updatedEntries } }) }, + + cancelRunningEntries: (workflowId: string) => { + set((state) => { + const updatedEntries = state.entries.map((entry) => { + if (entry.workflowId === workflowId && entry.isRunning) { + return { + ...entry, + isRunning: false, + isCanceled: true, + endedAt: new Date().toISOString(), + } + } + return entry + }) + return { entries: updatedEntries } + }) + }, }), { name: 'terminal-console-store', diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index f496c7356c..ca31112eb2 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 } From 3d51849018ebc512c38b9efa49b0deaac434ced6 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Wed, 28 Jan 2026 02:01:46 -0800 Subject: [PATCH 5/9] improvement(terminal): ui/ux --- .../filter-popover/filter-popover.tsx | 45 +- .../components/terminal/components/index.ts | 1 + .../log-row-context-menu.tsx | 32 +- .../components/output-panel/output-panel.tsx | 101 ++--- .../components/status-display/index.ts | 1 + .../status-display/status-display.tsx | 43 ++ .../terminal/hooks/use-output-panel-resize.ts | 6 +- .../terminal/hooks/use-terminal-filters.ts | 28 +- .../components/terminal/terminal.tsx | 383 +++--------------- .../terminal/{types.tsx => types.ts} | 49 +-- .../[workflowId]/components/terminal/utils.ts | 40 +- apps/sim/stores/constants.ts | 3 + 12 files changed, 153 insertions(+), 579 deletions(-) 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 rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/{types.tsx => types.ts} (55%) 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 index a0312bf5fd..3362e28de4 100644 --- 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 @@ -17,11 +17,7 @@ import type { BlockInfo, TerminalFilters, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' -import { - formatRunId, - getBlockIcon, - getRunIdColor, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' +import { getBlockIcon } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' /** * Props for the FilterPopover component @@ -32,10 +28,7 @@ export interface FilterPopoverProps { filters: TerminalFilters toggleStatus: (status: 'error' | 'info') => void toggleBlock: (blockId: string) => void - toggleRunId: (runId: string) => void uniqueBlocks: BlockInfo[] - uniqueRunIds: string[] - executionColorMap: Map hasActiveFilters: boolean } @@ -48,10 +41,7 @@ export const FilterPopover = memo(function FilterPopover({ filters, toggleStatus, toggleBlock, - toggleRunId, uniqueBlocks, - uniqueRunIds, - executionColorMap, hasActiveFilters, }: FilterPopoverProps) { return ( @@ -69,7 +59,7 @@ export const FilterPopover = memo(function FilterPopover({ e.stopPropagation()} @@ -103,7 +93,7 @@ export const FilterPopover = memo(function FilterPopover({ {uniqueBlocks.length > 0 && ( <> - + Blocks {uniqueBlocks.map((block) => { @@ -125,35 +115,6 @@ export const FilterPopover = memo(function FilterPopover({ )} - - {uniqueRunIds.length > 0 && ( - <> - - Run ID - - {uniqueRunIds.map((runId) => { - const isSelected = filters.runIds.has(runId) - const runIdColor = getRunIdColor(runId, executionColorMap) - - return ( - toggleRunId(runId)} - > - - {formatRunId(runId)} - - - ) - })} - - - )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts index 60a2038274..b230b8196a 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,4 +1,5 @@ export { FilterPopover, type FilterPopoverProps } from './filter-popover' export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu' export { OutputPanel, type OutputPanelProps } from './output-panel' +export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display' export { ToggleButton, type ToggleButtonProps } from './toggle-button' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/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 index b65b5ab767..be911a3c01 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/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 @@ -23,12 +23,9 @@ export interface LogRowContextMenuProps { filters: TerminalFilters onFilterByBlock: (blockId: string) => void onFilterByStatus: (status: 'error' | 'info') => void - onFilterByRunId: (runId: string) => void onCopyRunId: (runId: string) => void - onClearFilters: () => void onClearConsole: () => void onFixInCopilot: (entry: ConsoleEntry) => void - hasActiveFilters: boolean } /** @@ -44,19 +41,15 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ filters, onFilterByBlock, onFilterByStatus, - onFilterByRunId, onCopyRunId, - onClearFilters, onClearConsole, onFixInCopilot, - hasActiveFilters, }: LogRowContextMenuProps) { const hasRunId = entry?.executionId != null const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false const entryStatus = entry?.success ? 'info' : 'error' const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false - const isRunIdFiltered = entry?.executionId ? filters.runIds.has(entry.executionId) : false return ( 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() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx index 7fbeb73295..a9292f7066 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -9,7 +9,6 @@ import { Check, Clipboard, Database, - FilterX, MoreHorizontal, Palette, Pause, @@ -102,7 +101,6 @@ export interface OutputPanelProps { filteredEntries: ConsoleEntry[] handleExportConsole: (e: React.MouseEvent) => void hasActiveFilters: boolean - clearFilters: () => void handleClearConsole: (e: React.MouseEvent) => void shouldShowCodeDisplay: boolean outputDataStringified: string @@ -111,10 +109,7 @@ export interface OutputPanelProps { filters: TerminalFilters toggleBlock: (blockId: string) => void toggleStatus: (status: 'error' | 'info') => void - toggleRunId: (runId: string) => void uniqueBlocks: BlockInfo[] - uniqueRunIds: string[] - executionColorMap: Map } /** @@ -139,7 +134,6 @@ export const OutputPanel = React.memo(function OutputPanel({ filteredEntries, handleExportConsole, hasActiveFilters, - clearFilters, handleClearConsole, shouldShowCodeDisplay, outputDataStringified, @@ -148,10 +142,7 @@ export const OutputPanel = React.memo(function OutputPanel({ filters, toggleBlock, toggleStatus, - toggleRunId, uniqueBlocks, - uniqueRunIds, - executionColorMap, }: OutputPanelProps) { // Access store-backed settings directly to reduce prop drilling const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth) @@ -224,14 +215,6 @@ export const OutputPanel = React.memo(function OutputPanel({ setOpenOnRun(!openOnRun) }, [openOnRun, setOpenOnRun]) - const handleClearFiltersClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - clearFilters() - }, - [clearFilters] - ) - const handleCopyClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -364,10 +347,7 @@ export const OutputPanel = React.memo(function OutputPanel({ filters={filters} toggleStatus={toggleStatus} toggleBlock={toggleBlock} - toggleRunId={toggleRunId} uniqueBlocks={uniqueBlocks} - uniqueRunIds={uniqueRunIds} - executionColorMap={executionColorMap} hasActiveFilters={hasActiveFilters} /> )} @@ -470,55 +450,38 @@ export const OutputPanel = React.memo(function OutputPanel({ {filteredEntries.length > 0 && ( - - - - - - Download CSV - - - )} - {hasActiveFilters && ( - - - - - - Clear filters - - - )} - {filteredEntries.length > 0 && ( - - - - - - Clear console - - + <> + + + + + + Download CSV + + + + + + + + Clear console + + + )} 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 0000000000..0bc435a9a7 --- /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 0000000000..fa54725e2c --- /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/hooks/use-output-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts index d3d9f59e99..2c5fe43233 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 e5e6119270..1807828f4e 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 @@ -15,7 +15,6 @@ export function useTerminalFilters() { const [filters, setFilters] = useState({ blockIds: new Set(), statuses: new Set(), - runIds: new Set(), }) const [sortConfig, setSortConfig] = useState({ @@ -53,21 +52,6 @@ export function useTerminalFilters() { }) }, []) - /** - * Toggles a run ID filter - */ - const toggleRunId = useCallback((runId: string) => { - setFilters((prev) => { - const newRunIds = new Set(prev.runIds) - if (newRunIds.has(runId)) { - newRunIds.delete(runId) - } else { - newRunIds.add(runId) - } - return { ...prev, runIds: newRunIds } - }) - }, []) - /** * Toggles sort direction between ascending and descending */ @@ -85,7 +69,6 @@ export function useTerminalFilters() { setFilters({ blockIds: new Set(), statuses: new Set(), - runIds: new Set(), }) }, []) @@ -93,7 +76,7 @@ export function useTerminalFilters() { * Checks if any filters are active */ const hasActiveFilters = useMemo(() => { - return filters.blockIds.size > 0 || filters.statuses.size > 0 || filters.runIds.size > 0 + return filters.blockIds.size > 0 || filters.statuses.size > 0 }, [filters]) /** @@ -118,14 +101,6 @@ export function useTerminalFilters() { if (!hasStatus) return false } - // Run ID filter - if ( - filters.runIds.size > 0 && - (!entry.executionId || !filters.runIds.has(entry.executionId)) - ) { - return false - } - return true }) } @@ -148,7 +123,6 @@ export function useTerminalFilters() { sortConfig, toggleBlock, toggleStatus, - toggleRunId, toggleSort, clearFilters, hasActiveFilters, 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 49b78c9fb2..5886adcd27 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 @@ -8,7 +8,6 @@ import { ArrowDownToLine, ArrowUp, Database, - FilterX, MoreHorizontal, Palette, Pause, @@ -16,7 +15,6 @@ import { } from 'lucide-react' import Link from 'next/link' import { - Badge, Button, ChevronDown, Popover, @@ -32,6 +30,7 @@ import { FilterPopover, LogRowContextMenu, OutputPanel, + StatusDisplay, ToggleButton, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components' import { @@ -39,23 +38,17 @@ import { useTerminalFilters, useTerminalResize, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks' -import { - BADGE_STYLES, - ROW_STYLES, - StatusDisplay, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' +import { ROW_STYLES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' import { type EntryNode, type ExecutionGroup, flattenBlockEntriesOnly, formatDuration, - formatRunId, getBlockColor, getBlockIcon, groupEntriesByExecution, isEventFromEditableElement, type NavigableBlockEntry, - RUN_ID_COLORS, TERMINAL_CONFIG, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -165,7 +158,7 @@ const IterationNodeRow = memo(function IterationNodeRow({ const hasCanceledChild = children.some((c) => c.entry.isCanceled) && !hasRunningChild const iterationLabel = iterationInfo - ? `Iteration ${iterationInfo.current}${iterationInfo.total !== undefined ? ` / ${iterationInfo.total}` : ''}` + ? `Iteration ${iterationInfo.current + 1}${iterationInfo.total !== undefined ? ` / ${iterationInfo.total}` : ''}` : entry.blockName return ( @@ -398,124 +391,43 @@ const EntryNodeRow = memo(function EntryNodeRow({ }) /** - * Status badge component for execution rows - */ -const StatusBadge = memo(function StatusBadge({ - hasError, - isRunning, - isCanceled, -}: { - hasError: boolean - isRunning: boolean - isCanceled: boolean -}) { - if (isRunning) { - return ( - - Running - - ) - } - if (isCanceled) { - return ( - - canceled - - ) - } - return ( - - {hasError ? 'error' : 'info'} - - ) -}) - -/** - * Execution row component with expand/collapse + * Execution group row component with dashed separator */ -const ExecutionRow = memo(function ExecutionRow({ +const ExecutionGroupRow = memo(function ExecutionGroupRow({ group, - isExpanded, - onToggle, + showSeparator, selectedEntryId, onSelectEntry, expandedNodes, onToggleNode, }: { group: ExecutionGroup - isExpanded: boolean - onToggle: () => void + showSeparator: boolean selectedEntryId: string | null onSelectEntry: (entry: ConsoleEntry) => void expandedNodes: Set onToggleNode: (nodeId: string) => void }) { - const hasError = group.status === 'error' - const hasRunningEntry = group.entries.some((entry) => entry.isRunning) - const hasCanceledEntry = group.entries.some((entry) => entry.isCanceled) && !hasRunningEntry - return (
- {/* Execution header */} -
-
- - Run #{formatRunId(group.executionId)} - - - -
- - + )} + + {/* Entry tree */} +
+ {group.entryTree.map((node) => ( + - + ))}
- - {/* Expanded content - Tree structure */} - {isExpanded && ( -
-
- {group.entryTree.map((node) => ( - - ))} -
-
- )}
) }) @@ -526,7 +438,6 @@ const ExecutionRow = memo(function ExecutionRow({ export const Terminal = memo(function Terminal() { const terminalRef = useRef(null) const logsContainerRef = useRef(null) - const prevEntriesLengthRef = useRef(0) const prevWorkflowEntriesLengthRef = useRef(0) const hasInitializedEntriesRef = useRef(false) const isTerminalFocusedRef = useRef(false) @@ -544,10 +455,6 @@ export const Terminal = memo(function Terminal() { const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth) const openOnRun = useTerminalStore((state) => state.openOnRun) const setOpenOnRun = useTerminalStore((state) => state.setOpenOnRun) - const wrapText = useTerminalStore((state) => state.wrapText) - const setWrapText = useTerminalStore((state) => state.setWrapText) - const structuredView = useTerminalStore((state) => state.structuredView) - const setStructuredView = useTerminalStore((state) => state.setStructuredView) const setHasHydrated = useTerminalStore((state) => state.setHasHydrated) const isExpanded = useTerminalStore( (state) => state.terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD @@ -566,7 +473,6 @@ export const Terminal = memo(function Terminal() { const exportConsoleCSV = useTerminalConsoleStore((state) => state.exportConsoleCSV) const [selectedEntry, setSelectedEntry] = useState(null) - const [expandedExecutions, setExpandedExecutions] = useState>(new Set()) const [expandedNodes, setExpandedNodes] = useState>(new Set()) const [isToggling, setIsToggling] = useState(false) const [showCopySuccess, setShowCopySuccess] = useState(false) @@ -589,7 +495,6 @@ export const Terminal = memo(function Terminal() { sortConfig, toggleBlock, toggleStatus, - toggleRunId, toggleSort, clearFilters, filterEntries, @@ -635,7 +540,7 @@ export const Terminal = memo(function Terminal() { /** * Navigable block entries for keyboard navigation. - * Only includes actual block outputs (not subflows/iterations/headers). + * Only includes actual block outputs (excludes subflow/iteration container nodes). * Includes parent node IDs for auto-expanding when navigating. */ const navigableEntries = useMemo(() => { @@ -663,65 +568,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]) - - /** - * Track color offset for run IDs - */ - const colorStateRef = useRef<{ executionIds: string[]; offset: number }>({ - executionIds: [], - offset: 0, - }) - - /** - * Compute colors for each execution ID - */ - const executionColorMap = useMemo(() => { - const currentIds: string[] = [] - const seen = new Set() - 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 */ @@ -815,7 +661,7 @@ export const Terminal = memo(function Terminal() { ]) /** - * Auto-expand newest execution, subflows, and iterations when new entries arrive. + * Auto-expand subflows and iterations when new entries arrive. * This always runs regardless of autoSelectEnabled - new runs should always be visible. */ useEffect(() => { @@ -823,14 +669,6 @@ export const Terminal = memo(function Terminal() { const newestExec = executionGroups[0] - // Always expand the newest execution group - setExpandedExecutions((prev) => { - if (prev.has(newestExec.executionId)) return prev - const next = new Set(prev) - next.add(newestExec.executionId) - return next - }) - // Collect all node IDs that should be expanded (subflows and their iterations) const nodeIdsToExpand: string[] = [] for (const node of newestExec.entryTree) { @@ -865,35 +703,20 @@ export const Terminal = memo(function Terminal() { }, []) /** - * Handle entry selection + * Handle entry selection - clicking same entry toggles selection off */ const handleSelectEntry = useCallback( (entry: ConsoleEntry) => { focusTerminal() setSelectedEntry((prev) => { - const isDeselecting = prev?.id === entry.id - setAutoSelectEnabled(isDeselecting) - return isDeselecting ? null : entry + // Disable auto-select on any manual selection/deselection + setAutoSelectEnabled(false) + return prev?.id === entry.id ? null : entry }) }, [focusTerminal] ) - /** - * Toggle execution expansion - */ - const handleToggleExecution = useCallback((executionId: string) => { - setExpandedExecutions((prev) => { - const next = new Set(prev) - if (next.has(executionId)) { - next.delete(executionId) - } else { - next.add(executionId) - } - return next - }) - }, []) - /** * Toggle subflow node expansion */ @@ -943,7 +766,6 @@ export const Terminal = memo(function Terminal() { if (activeWorkflowId) { clearWorkflowConsole(activeWorkflowId) setSelectedEntry(null) - setExpandedExecutions(new Set()) setExpandedNodes(new Set()) } }, [activeWorkflowId, clearWorkflowConsole]) @@ -982,14 +804,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) @@ -1084,85 +898,57 @@ export const Terminal = memo(function Terminal() { } }, [showCopySuccess]) - /** - * Scroll the logs container to the bottom. - */ - const scrollToBottom = useCallback(() => { - requestAnimationFrame(() => { - const container = logsContainerRef.current - if (!container) return - container.scrollTop = container.scrollHeight - }) - }, []) - - /** - * Scroll an entry into view (for keyboard navigation). - */ const scrollEntryIntoView = useCallback((entryId: string) => { - requestAnimationFrame(() => { - const container = logsContainerRef.current - if (!container) return - const el = container.querySelector(`[data-entry-id="${entryId}"]`) - if (el) { - el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) - } - }) + const container = logsContainerRef.current + if (!container) return + const el = container.querySelector(`[data-entry-id="${entryId}"]`) + if (el) { + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + } }, []) - /** - * Auto-select the last entry (bottom of the list) when new logs arrive. - */ useEffect(() => { if (executionGroups.length === 0 || navigableEntries.length === 0) { setAutoSelectEnabled(true) setSelectedEntry(null) - prevEntriesLengthRef.current = 0 return } - if (autoSelectEnabled && navigableEntries.length > prevEntriesLengthRef.current) { - // Get the last entry from the newest execution (it's at the bottom of the list) - const newestExecutionId = executionGroups[0].executionId - let lastNavEntry: NavigableBlockEntry | null = null + if (!autoSelectEnabled) return - for (const navEntry of navigableEntries) { - if (navEntry.executionId === newestExecutionId) { - lastNavEntry = navEntry - } else { - break - } - } + const newestExecutionId = executionGroups[0].executionId + let lastNavEntry: NavigableBlockEntry | null = null - if (!lastNavEntry) { - prevEntriesLengthRef.current = navigableEntries.length - return + for (const navEntry of navigableEntries) { + if (navEntry.executionId === newestExecutionId) { + lastNavEntry = navEntry + } else { + break } + } - setSelectedEntry(lastNavEntry.entry) - focusTerminal() + if (!lastNavEntry) return + if (selectedEntry?.id === lastNavEntry.entry.id) return - // Expand execution and parent nodes - setExpandedExecutions((prev) => { - if (prev.has(lastNavEntry.executionId)) return prev + 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) - next.add(lastNavEntry.executionId) + lastNavEntry.parentNodeIds.forEach((id) => next.add(id)) return next }) - if (lastNavEntry.parentNodeIds.length > 0) { - setExpandedNodes((prev) => { - const hasAll = lastNavEntry.parentNodeIds.every((id) => prev.has(id)) - if (hasAll) return prev - const next = new Set(prev) - lastNavEntry.parentNodeIds.forEach((id) => next.add(id)) - return next - }) - } - - scrollToBottom() } + }, [executionGroups, navigableEntries, autoSelectEnabled, selectedEntry?.id, focusTerminal]) - prevEntriesLengthRef.current = navigableEntries.length - }, [executionGroups, navigableEntries, autoSelectEnabled, focusTerminal, scrollToBottom]) + useEffect(() => { + if (selectedEntry) { + scrollEntryIntoView(selectedEntry.id) + } + }, [selectedEntry?.id, scrollEntryIntoView]) /** * Sync selected entry with latest data from store. @@ -1204,14 +990,6 @@ export const Terminal = memo(function Terminal() { setAutoSelectEnabled(false) setSelectedEntry(navEntry.entry) - // Auto-expand the execution group - setExpandedExecutions((prev) => { - if (prev.has(navEntry.executionId)) return prev - const next = new Set(prev) - next.add(navEntry.executionId) - return next - }) - // Auto-expand parent nodes (subflows, iterations) if (navEntry.parentNodeIds.length > 0) { setExpandedNodes((prev) => { @@ -1401,10 +1179,7 @@ export const Terminal = memo(function Terminal() { filters={filters} toggleStatus={toggleStatus} toggleBlock={toggleBlock} - toggleRunId={toggleRunId} uniqueBlocks={uniqueBlocks} - uniqueRunIds={uniqueRunIds} - executionColorMap={executionColorMap} hasActiveFilters={hasActiveFilters} /> )} @@ -1479,27 +1254,6 @@ export const Terminal = memo(function Terminal() { )} - {hasActiveFilters && ( - - - - - - Clear filters - - - )} - {filteredEntries.length > 0 && ( <> @@ -1588,12 +1342,11 @@ export const Terminal = memo(function Terminal() { No logs yet
) : ( - executionGroups.map((group) => ( - ( + handleToggleExecution(group.executionId)} + showSeparator={index > 0} selectedEntryId={selectedEntry?.id || null} onSelectEntry={handleSelectEntry} expandedNodes={expandedNodes} @@ -1624,7 +1377,6 @@ export const Terminal = memo(function Terminal() { filteredEntries={filteredEntries} handleExportConsole={handleExportConsole} hasActiveFilters={hasActiveFilters} - clearFilters={clearFilters} handleClearConsole={handleClearConsole} shouldShowCodeDisplay={shouldShowCodeDisplay} outputDataStringified={outputDataStringified} @@ -1633,10 +1385,7 @@ export const Terminal = memo(function Terminal() { filters={filters} toggleBlock={toggleBlock} toggleStatus={toggleStatus} - toggleRunId={toggleRunId} uniqueBlocks={uniqueBlocks} - uniqueRunIds={uniqueRunIds} - executionColorMap={executionColorMap} /> )}
@@ -1652,15 +1401,9 @@ export const Terminal = memo(function Terminal() { filters={filters} onFilterByBlock={handleFilterByBlock} onFilterByStatus={handleFilterByStatus} - onFilterByRunId={handleFilterByRunId} onCopyRunId={handleCopyRunId} - onClearFilters={() => { - clearFilters() - closeLogRowMenu() - }} onClearConsole={handleClearConsoleFromMenu} onFixInCopilot={handleFixInCopilot} - hasActiveFilters={hasActiveFilters} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts similarity index 55% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts index 35c8607a48..a9029f8149 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts @@ -1,15 +1,9 @@ -'use client' - -import { memo } from 'react' -import { Badge } from '@/components/emcn' - /** * Terminal filter configuration state */ export interface TerminalFilters { blockIds: Set statuses: Set<'error' | 'info'> - runIds: Set } /** @@ -67,45 +61,4 @@ export const ROW_STYLES = { /** * Common badge styling for status badges */ -export const BADGE_STYLES = { - base: 'rounded-[4px] px-[4px] py-[0px] text-[11px]', - mono: 'rounded-[4px] px-[4px] py-[0px] font-mono text-[11px]', -} as const - -/** - * Running badge component - displays a consistent "Running" indicator - */ -export const RunningBadge = memo(function RunningBadge() { - return ( - - 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} -}) +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 index c0a9dfca29..39d3770b31 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts @@ -1,8 +1,7 @@ -'use client' - import type React from 'react' import { RepeatIcon, SplitIcon } from 'lucide-react' import { getBlock } from '@/blocks' +import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants' import type { ConsoleEntry } from '@/stores/terminal' /** @@ -13,20 +12,6 @@ const SUBFLOW_COLORS = { parallel: '#FEE12B', } as const -/** - * Run ID color palette for visual distinction between executions - */ -export const RUN_ID_COLORS = [ - '#4ADE80', // Green - '#F472B6', // Pink - '#60C5FF', // Blue - '#FF8533', // Orange - '#C084FC', // Purple - '#EAB308', // Yellow - '#2DD4BF', // Teal - '#FB7185', // Rose -] as const - /** * Retrieves the icon component for a given block type */ @@ -77,25 +62,6 @@ export function formatDuration(ms?: number): string { return `${(ms / 1000).toFixed(2)}s` } -/** - * Truncates execution ID for display as run ID - */ -export function formatRunId(executionId?: string): string { - if (!executionId) return '-' - return executionId.slice(0, 8) -} - -/** - * Gets color for a run ID from the precomputed color map - */ -export function getRunIdColor( - executionId: string | undefined, - colorMap: Map -): string | null { - if (!executionId) return null - return colorMap.get(executionId) ?? null -} - /** * Determines if a keyboard event originated from a text-editable element */ @@ -476,13 +442,11 @@ export function flattenBlockEntriesOnly( return result } -// BlockInfo is now in types.ts for shared use across terminal components - /** * Terminal height configuration constants */ export const TERMINAL_CONFIG = { NEAR_MIN_THRESHOLD: 40, - BLOCK_COLUMN_WIDTH_PX: 240, + BLOCK_COLUMN_WIDTH_PX: TERMINAL_BLOCK_COLUMN_WIDTH, HEADER_TEXT_CLASS: 'font-medium text-[var(--text-tertiary)] text-[12px]', } as const diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index d2eb2bfd3b..86b4fc36c1 100644 --- a/apps/sim/stores/constants.ts +++ b/apps/sim/stores/constants.ts @@ -61,3 +61,6 @@ export const OUTPUT_PANEL_WIDTH = { DEFAULT: 440, MIN: 440, } as const + +/** Terminal block column width - minimum width for the logs column */ +export const TERMINAL_BLOCK_COLUMN_WIDTH = 240 as const From 9076003e8917b95d6f9a43f828152aea674e9fc3 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Wed, 28 Jan 2026 12:25:37 -0800 Subject: [PATCH 6/9] improvement(terminal): default sizing and collapsed width --- apps/sim/app/_styles/globals.css | 2 +- .../components/terminal/terminal.tsx | 18 ++++++++++++------ apps/sim/stores/constants.ts | 6 +++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index a177876575..96e8981e3f 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]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 5886adcd27..342d5e131c 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 @@ -410,10 +410,8 @@ const ExecutionGroupRow = memo(function ExecutionGroupRow({ }) { return (
- {/* Dashed separator between executions */} - {showSeparator && ( -
- )} + {/* Separator between executions */} + {showSeparator &&
} {/* Entry tree */}
@@ -1091,7 +1089,8 @@ export const Terminal = memo(function Terminal() { }, [expandToLastHeight, navigateToEntry]) /** - * Adjust output panel width on resize + * Adjust output panel width on resize. + * Closes the output panel if there's not enough space for the minimum width. */ useEffect(() => { const handleResize = () => { @@ -1107,7 +1106,14 @@ export const Terminal = memo(function Terminal() { const terminalWidth = window.innerWidth - sidebarWidth - panelWidth 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)) } } diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index 86b4fc36c1..d22db32c9f 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,8 +58,8 @@ 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 */ From 181b6975966ddeb22bb08e33f9f84bb39bcd628e Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Wed, 28 Jan 2026 12:57:09 -0800 Subject: [PATCH 7/9] fix: code colors, terminal large output handling --- .../components/structured-output.tsx | 516 ++++++++++++++---- .../components/emcn/components/code/code.css | 6 +- 2 files changed, 415 insertions(+), 107 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx index 678dd6a4b6..b67f63a399 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx @@ -11,6 +11,7 @@ import { 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' @@ -24,9 +25,8 @@ interface NodeEntry { } /** - * Search context for the structured output tree. - * Separates stable values (query, pathToMatchIndices) from frequently changing currentMatchIndex - * to avoid unnecessary re-renders of the entire tree. + * Search context for structured output tree. + * Separates stable values from frequently changing currentMatchIndex to avoid re-renders. */ interface SearchContextValue { query: string @@ -36,6 +36,18 @@ interface SearchContextValue { 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', @@ -46,6 +58,9 @@ const BADGE_VARIANTS: Record = { 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: @@ -56,7 +71,7 @@ const STYLES = { summary: 'text-[12px] text-[var(--text-tertiary)]', indent: 'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]', - value: 'py-[2px] text-[13px] text-[var(--text-primary)]', + 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', @@ -64,9 +79,6 @@ const STYLES = { const EMPTY_MATCH_INDICES: number[] = [] -/** - * Returns the type label for a value - */ function getTypeLabel(value: unknown): ValueType { if (value === null) return 'null' if (value === undefined) return 'undefined' @@ -74,34 +86,22 @@ function getTypeLabel(value: unknown): ValueType { return typeof value as ValueType } -/** - * Formats a primitive value for display - */ function formatPrimitive(value: unknown): string { if (value === null) return 'null' if (value === undefined) return 'undefined' return String(value) } -/** - * Checks if a value is a primitive (not object/array) - */ function isPrimitive(value: unknown): value is null | undefined | string | number | boolean { return value === null || value === undefined || typeof value !== 'object' } -/** - * Checks if a value is an empty object or array - */ function isEmpty(value: unknown): boolean { if (Array.isArray(value)) return value.length === 0 if (typeof value === 'object' && value !== null) return Object.keys(value).length === 0 return false } -/** - * Extracts error message from various error data formats - */ function extractErrorMessage(data: unknown): string { if (typeof data === 'string') return data if (data instanceof Error) return data.message @@ -111,9 +111,6 @@ function extractErrorMessage(data: unknown): string { return JSON.stringify(data, null, 2) } -/** - * Builds node entries from an object or array value - */ function buildEntries(value: unknown, basePath: string): NodeEntry[] { if (Array.isArray(value)) { return value.map((item, i) => ({ key: String(i), value: item, path: `${basePath}[${i}]` })) @@ -125,9 +122,6 @@ function buildEntries(value: unknown, basePath: string): NodeEntry[] { })) } -/** - * Gets the count summary for collapsed arrays/objects - */ function getCollapsedSummary(value: unknown): string | null { if (Array.isArray(value)) { const len = value.length @@ -140,9 +134,6 @@ function getCollapsedSummary(value: unknown): string | null { return null } -/** - * Computes initial expanded paths for first-level items - */ function computeInitialPaths(data: unknown, isError: boolean): Set { if (isError) return new Set(['root.error']) if (!data || typeof data !== 'object') return new Set() @@ -152,9 +143,6 @@ function computeInitialPaths(data: unknown, isError: boolean): Set { return new Set(entries) } -/** - * Gets all ancestor paths needed to reach a given path - */ function getAncestorPaths(path: string): string[] { const ancestors: string[] = [] let current = path @@ -169,9 +157,6 @@ function getAncestorPaths(path: string): string[] { return ancestors } -/** - * Finds all case-insensitive matches of a query within text - */ function findTextMatches(text: string, query: string): Array<[number, number]> { if (!query) return [] @@ -190,9 +175,6 @@ function findTextMatches(text: string, query: string): Array<[number, number]> { return matches } -/** - * Adds match entries for a primitive value at the given path - */ function addPrimitiveMatches(value: unknown, path: string, query: string, matches: string[]): void { const text = formatPrimitive(value) const count = findTextMatches(text, query).length @@ -201,11 +183,8 @@ function addPrimitiveMatches(value: unknown, path: string, query: string, matche } } -/** - * Recursively collects all match paths across the entire data tree - */ -function collectAllMatchPaths(data: unknown, query: string, basePath: string): string[] { - if (!query) return [] +function collectAllMatchPaths(data: unknown, query: string, basePath: string, depth = 0): string[] { + if (!query || depth > CONFIG.MAX_SEARCH_DEPTH) return [] const matches: string[] = [] @@ -218,16 +197,13 @@ function collectAllMatchPaths(data: unknown, query: string, basePath: string): s if (isPrimitive(entry.value)) { addPrimitiveMatches(entry.value, entry.path, query, matches) } else { - matches.push(...collectAllMatchPaths(entry.value, query, entry.path)) + matches.push(...collectAllMatchPaths(entry.value, query, entry.path, depth + 1)) } } return matches } -/** - * Builds a map from path to array of global match indices - */ function buildPathToIndicesMap(matchPaths: string[]): Map { const map = new Map() matchPaths.forEach((path, globalIndex) => { @@ -241,29 +217,20 @@ function buildPathToIndicesMap(matchPaths: string[]): Map { return map } -interface HighlightedTextProps { - text: string - matchIndices: number[] - path: string -} - /** - * Renders text with search highlights. - * Uses context to access search state and avoid prop drilling. + * Renders text with search highlights using segments. */ -const HighlightedText = memo(function HighlightedText({ - text, - matchIndices, - path, -}: HighlightedTextProps) { - const searchContext = useContext(SearchContext) - - if (!searchContext || matchIndices.length === 0) return <>{text} - - const textMatches = findTextMatches(text, searchContext.query) - if (textMatches.length === 0) return <>{text} +function renderHighlightedSegments( + text: string, + query: string, + matchIndices: number[], + currentMatchIndex: number, + path: string +): React.ReactNode { + if (!query || matchIndices.length === 0) return text - const currentMatchIndex = searchContext.currentMatchIndexRef.current + const textMatches = findTextMatches(text, query) + if (textMatches.length === 0) return text const segments: React.ReactNode[] = [] let lastEnd = 0 @@ -297,6 +264,37 @@ const HighlightedText = memo(function HighlightedText({ } return <>{segments} +} + +interface HighlightedTextProps { + text: string + matchIndices: number[] + path: string +} + +/** + * Renders text with search highlights for non-virtualized mode. + */ +const HighlightedText = memo(function HighlightedText({ + text, + matchIndices, + path, +}: HighlightedTextProps) { + const searchContext = useContext(SearchContext) + + if (!searchContext || matchIndices.length === 0) return <>{text} + + return ( + <> + {renderHighlightedSegments( + text, + searchContext.query, + matchIndices, + searchContext.currentMatchIndexRef.current, + path + )} + + ) }) interface StructuredNodeProps { @@ -310,8 +308,8 @@ interface StructuredNodeProps { } /** - * Recursive node component for rendering structured data. - * Uses context for search state to avoid re-renders when currentMatchIndex changes. + * Recursive node component for non-virtualized rendering. + * Preserves exact original styling with border-left tree lines. */ const StructuredNode = memo(function StructuredNode({ name, @@ -406,6 +404,250 @@ const StructuredNode = memo(function StructuredNode({ ) }) +/** + * 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 @@ -420,8 +662,8 @@ export interface StructuredOutputProps { /** * Renders structured data as nested collapsible blocks. - * Supports search with highlighting, auto-expand, and scroll-to-match. - * Uses React Context for search state to prevent re-render cascade. + * 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, @@ -441,11 +683,12 @@ export const StructuredOutput = memo(function StructuredOutput({ const prevIsErrorRef = useRef(isError) const internalRef = useRef(null) const currentMatchIndexRef = useRef(currentMatchIndex) + const listRef = useListRef(null) + const [containerHeight, setContainerHeight] = useState(400) - // Keep ref in sync currentMatchIndexRef.current = currentMatchIndex - // Force re-render of highlighted text when currentMatchIndex changes + // Force re-render when currentMatchIndex changes const [, forceUpdate] = useState(0) useEffect(() => { forceUpdate((n) => n + 1) @@ -461,6 +704,20 @@ export const StructuredOutput = memo(function StructuredOutput({ [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 @@ -485,6 +742,7 @@ export const StructuredOutput = memo(function StructuredOutput({ const pathToMatchIndices = useMemo(() => buildPathToIndicesMap(allMatchPaths), [allMatchPaths]) + // Auto-expand to current match useEffect(() => { if ( allMatchPaths.length === 0 || @@ -505,19 +763,6 @@ export const StructuredOutput = memo(function StructuredOutput({ }) }, [currentMatchIndex, allMatchPaths]) - useEffect(() => { - if (allMatchPaths.length === 0) return - - const rafId = requestAnimationFrame(() => { - const match = internalRef.current?.querySelector( - `[data-match-index="${currentMatchIndex}"]` - ) as HTMLElement | null - match?.scrollIntoView({ block: 'center', behavior: 'smooth' }) - }) - - return () => cancelAnimationFrame(rafId) - }, [currentMatchIndex, allMatchPaths.length, expandedPaths]) - const handleToggle = useCallback((path: string) => { setExpandedPaths((prev) => { const next = new Set(prev) @@ -531,25 +776,58 @@ export const StructuredOutput = memo(function StructuredOutput({ }, []) const rootEntries = useMemo(() => { - if (isPrimitive(data)) { - return [{ key: 'value', value: data, path: 'root.value' }] - } + if (isPrimitive(data)) return [{ key: 'value', value: data, path: 'root.value' }] return buildEntries(data, 'root') }, [data]) - // Create stable search context value - only changes when query or pathToMatchIndices change const searchContextValue = useMemo(() => { if (!searchQuery) return null - return { - query: searchQuery, - pathToMatchIndices, - currentMatchIndexRef, - } + return { query: searchQuery, pathToMatchIndices, currentMatchIndexRef } }, [searchQuery, pathToMatchIndices]) - const containerClass = cn('flex flex-col pl-[20px]', className) + 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]) - // Show "Running" badge when running with undefined data + 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 (
@@ -563,6 +841,44 @@ export const StructuredOutput = memo(function StructuredOutput({ ) } + // Empty state + if (rootEntries.length === 0 && !isError) { + return ( +
+ null +
+ ) + } + + // Virtualized rendering + if (useVirtualization) { + return ( +
+ +
+ ) + } + + // Non-virtualized rendering (preserves exact original styling) if (isError) { return ( @@ -581,14 +897,6 @@ export const StructuredOutput = memo(function StructuredOutput({ ) } - if (rootEntries.length === 0) { - return ( -
- null -
- ) - } - return (
diff --git a/apps/sim/components/emcn/components/code/code.css b/apps/sim/components/emcn/components/code/code.css index 11083892e6..be90a337a4 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, From 6a4deae6a29beb86dfc7c969ce470068c5f6c287 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Wed, 28 Jan 2026 13:00:55 -0800 Subject: [PATCH 8/9] fix(terminal): structured search --- .../components/structured-output.tsx | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx index b67f63a399..62d404d7a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx @@ -26,12 +26,10 @@ interface NodeEntry { /** * Search context for structured output tree. - * Separates stable values from frequently changing currentMatchIndex to avoid re-renders. */ interface SearchContextValue { query: string pathToMatchIndices: Map - currentMatchIndexRef: React.RefObject } const SearchContext = createContext(null) @@ -270,15 +268,18 @@ 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) @@ -286,13 +287,7 @@ const HighlightedText = memo(function HighlightedText({ return ( <> - {renderHighlightedSegments( - text, - searchContext.query, - matchIndices, - searchContext.currentMatchIndexRef.current, - path - )} + {renderHighlightedSegments(text, searchContext.query, matchIndices, currentMatchIndex, path)} ) }) @@ -304,6 +299,7 @@ interface StructuredNodeProps { expandedPaths: Set onToggle: (path: string) => void wrapText: boolean + currentMatchIndex: number isError?: boolean } @@ -318,6 +314,7 @@ const StructuredNode = memo(function StructuredNode({ expandedPaths, onToggle, wrapText, + currentMatchIndex, isError = false, }: StructuredNodeProps) { const searchContext = useContext(SearchContext) @@ -381,7 +378,12 @@ const StructuredNode = memo(function StructuredNode({ wrapText ? '[word-break:break-word]' : 'whitespace-nowrap' )} > - +
) : isEmptyValue ? (
{Array.isArray(value) ? '[]' : '{}'}
@@ -395,6 +397,7 @@ const StructuredNode = memo(function StructuredNode({ expandedPaths={expandedPaths} onToggle={onToggle} wrapText={wrapText} + currentMatchIndex={currentMatchIndex} /> )) )} @@ -682,18 +685,9 @@ export const StructuredOutput = memo(function StructuredOutput({ const prevDataRef = useRef(data) const prevIsErrorRef = useRef(isError) const internalRef = useRef(null) - const currentMatchIndexRef = useRef(currentMatchIndex) const listRef = useListRef(null) const [containerHeight, setContainerHeight] = useState(400) - currentMatchIndexRef.current = currentMatchIndex - - // Force re-render when currentMatchIndex changes - const [, forceUpdate] = useState(0) - useEffect(() => { - forceUpdate((n) => n + 1) - }, [currentMatchIndex]) - const setContainerRef = useCallback( (node: HTMLDivElement | null) => { ;(internalRef as React.MutableRefObject).current = node @@ -782,7 +776,7 @@ export const StructuredOutput = memo(function StructuredOutput({ const searchContextValue = useMemo(() => { if (!searchQuery) return null - return { query: searchQuery, pathToMatchIndices, currentMatchIndexRef } + return { query: searchQuery, pathToMatchIndices } }, [searchQuery, pathToMatchIndices]) const visibleRowCount = useMemo( @@ -890,6 +884,7 @@ export const StructuredOutput = memo(function StructuredOutput({ expandedPaths={expandedPaths} onToggle={handleToggle} wrapText={wrapText} + currentMatchIndex={currentMatchIndex} isError />
@@ -909,6 +904,7 @@ export const StructuredOutput = memo(function StructuredOutput({ expandedPaths={expandedPaths} onToggle={handleToggle} wrapText={wrapText} + currentMatchIndex={currentMatchIndex} /> ))}
From 8db44307868ff1c958c12607e18925b4ef10e3eb Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Wed, 28 Jan 2026 14:31:12 -0800 Subject: [PATCH 9/9] improvement: preivew accuracy, invite-modal admin, logs live --- .../app/workspace/[workspaceId]/logs/logs.tsx | 2 +- .../preview-workflow/preview-workflow.tsx | 131 ++++++------------ .../components/invite-modal/invite-modal.tsx | 2 +- 3 files changed, 48 insertions(+), 87 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 16d5e4bc58..aa7311fa59 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -78,7 +78,7 @@ export default function Logs() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const [isLive, setIsLive] = useState(false) + const [isLive, setIsLive] = useState(true) const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const [isExporting, setIsExporting] = useState(false) const isSearchOpenRef = useRef(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx index b18b504194..68de80b5cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx @@ -23,11 +23,7 @@ import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/type const logger = createLogger('PreviewWorkflow') -/** - * Gets block dimensions for preview purposes. - * For containers, uses stored dimensions or defaults. - * For regular blocks, uses stored height or estimates based on type. - */ +/** Gets block dimensions, using stored values or defaults. */ function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } { if (block.type === 'loop' || block.type === 'parallel') { return { @@ -50,10 +46,7 @@ function getPreviewBlockDimensions(block: BlockState): { width: number; height: return estimateBlockDimensions(block.type) } -/** - * Calculates container dimensions based on child block positions and sizes. - * Mirrors the logic from useNodeUtilities.calculateLoopDimensions. - */ +/** Calculates container dimensions from child block positions. */ function calculateContainerDimensions( containerId: string, blocks: Record @@ -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: '' } @@ -444,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 sourceStatus = getBlockExecutionStatus(edge.source) const targetStatus = getBlockExecutionStatus(edge.target) - const isErrorEdge = edge.sourceHandle === 'error' - - if (isErrorEdge) { - return sourceStatus?.status === 'error' && targetStatus?.executed - ? 'success' - : 'not-executed' - } + if (!targetStatus?.executed) return 'not-executed' - const isSubflowStartEdge = - edge.sourceHandle === 'loop-start-source' || edge.sourceHandle === 'parallel-start-source' + const sourceStatus = getBlockExecutionStatus(edge.source) + const { sourceHandle } = edge - 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' + if (sourceHandle === 'error') { + return sourceStatus?.status === 'error' ? '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) => { @@ -507,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 660389c247..daa4817b71 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', }, ]) }