From efa082d7e06f01b9bab78de5de2e0947e1e01f79 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Thu, 22 Jan 2026 10:39:31 -0800 Subject: [PATCH 1/2] improvement(action-bar): ordering --- .../w/[workflowId]/components/action-bar/action-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index 9792407fec..42d2c3e84e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -128,7 +128,7 @@ export const ActionBar = memo( 'dark:border-transparent dark:bg-[var(--surface-4)]' )} > - {!isNoteBlock && !isSubflowBlock && ( + {!isNoteBlock && ( + + {copied ? 'Copied' : 'Copy'} + + + + + + Search + + + )} {/* Search Overlay */} @@ -572,13 +482,10 @@ function InputOutputSection({ height: '1px', }} /> - + Copy Search - - Wrap Text - , document.body @@ -589,355 +496,229 @@ function InputOutputSection({ ) } -interface NestedBlockItemProps { +interface TraceSpanNodeProps { span: TraceSpan - parentId: string - index: number - expandedSections: Set - onToggle: (section: string) => void workflowStartTime: number totalDuration: number - expandedChildren: Set - onToggleChildren: (spanId: string) => void + depth: number + expandedNodes: Set + expandedSections: Set + onToggleNode: (nodeId: string) => void + onToggleSection: (section: string) => void } /** - * Recursive component for rendering nested blocks at any depth + * Recursive tree node component for rendering trace spans */ -function NestedBlockItem({ +const TraceSpanNode = memo(function TraceSpanNode({ span, - parentId, - index, - expandedSections, - onToggle, workflowStartTime, totalDuration, - expandedChildren, - onToggleChildren, -}: NestedBlockItemProps): React.ReactNode { - const spanId = span.id || `${parentId}-nested-${index}` - const isError = span.status === 'error' - const { icon: SpanIcon, bgColor } = getBlockIconAndColor(span.type, span.name) - const hasChildren = Boolean(span.children && span.children.length > 0) - const isChildrenExpanded = expandedChildren.has(spanId) - - return ( -
- onToggleChildren(spanId)} - /> - - - - {/* Nested children */} - {hasChildren && isChildrenExpanded && ( -
- {span.children!.map((child, childIndex) => ( - - ))} -
- )} -
- ) -} - -interface TraceSpanItemProps { - span: TraceSpan - totalDuration: number - workflowStartTime: number - isFirstSpan?: boolean -} - -/** - * Individual trace span card component. - * Memoized to prevent re-renders when sibling spans change. - */ -const TraceSpanItem = memo(function TraceSpanItem({ - span, - totalDuration, - workflowStartTime, - isFirstSpan = false, -}: TraceSpanItemProps): React.ReactNode { - const [expandedSections, setExpandedSections] = useState>(new Set()) - const [expandedChildren, setExpandedChildren] = useState>(new Set()) - const [isCardExpanded, setIsCardExpanded] = useState(false) - const toggleSet = useSetToggle() - + depth, + expandedNodes, + expandedSections, + onToggleNode, + onToggleSection, +}: TraceSpanNodeProps): React.ReactNode { const spanId = span.id || `span-${span.name}-${span.startTime}` const spanStartTime = new Date(span.startTime).getTime() const spanEndTime = new Date(span.endTime).getTime() const duration = span.duration || spanEndTime - spanStartTime - const hasChildren = Boolean(span.children && span.children.length > 0) - const hasToolCalls = Boolean(span.toolCalls && span.toolCalls.length > 0) - const isError = span.status === 'error' - - const inlineChildTypes = new Set([ - 'tool', - 'model', - 'loop-iteration', - 'parallel-iteration', - 'workflow', - ]) - - // For workflow-in-workflow blocks, all children should be rendered inline/nested - const isWorkflowBlock = span.type?.toLowerCase().includes('workflow') - const inlineChildren = isWorkflowBlock - ? span.children || [] - : span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || [] - const otherChildren = isWorkflowBlock - ? [] - : span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || [] - - const toolCallSpans = useMemo(() => { - if (!hasToolCalls) return [] - return span.toolCalls!.map((toolCall, index) => { - const toolStartTime = toolCall.startTime - ? new Date(toolCall.startTime).getTime() - : spanStartTime - const toolEndTime = toolCall.endTime - ? new Date(toolCall.endTime).getTime() - : toolStartTime + (toolCall.duration || 0) - - return { - id: `${spanId}-tool-${index}`, - name: toolCall.name, - type: 'tool', - duration: toolCall.duration || toolEndTime - toolStartTime, - startTime: new Date(toolStartTime).toISOString(), - endTime: new Date(toolEndTime).toISOString(), - status: toolCall.error ? ('error' as const) : ('success' as const), - input: toolCall.input, - output: toolCall.error - ? { error: toolCall.error, ...(toolCall.output || {}) } - : toolCall.output, - } as TraceSpan - }) - }, [hasToolCalls, span.toolCalls, spanId, spanStartTime]) - - const handleSectionToggle = useCallback( - (section: string) => toggleSet(setExpandedSections, section), - [toggleSet] - ) - - const handleChildrenToggle = useCallback( - (childSpanId: string) => toggleSet(setExpandedChildren, childSpanId), - [toggleSet] - ) + const isDirectError = span.status === 'error' + const hasNestedError = hasErrorInTree(span) + const showErrorStyle = isDirectError || hasNestedError const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name) - // Check if this card has expandable inline content - const hasInlineContent = - (isWorkflowBlock && inlineChildren.length > 0) || - (!isWorkflowBlock && (toolCallSpans.length > 0 || inlineChildren.length > 0)) + // Root workflow execution is always expanded and has no toggle + const isRootWorkflow = depth === 0 + + // Build all children including tool calls + const allChildren = useMemo(() => { + const children: TraceSpan[] = [] + + // Add tool calls as child spans + if (span.toolCalls && span.toolCalls.length > 0) { + span.toolCalls.forEach((toolCall, index) => { + const toolStartTime = toolCall.startTime + ? new Date(toolCall.startTime).getTime() + : spanStartTime + const toolEndTime = toolCall.endTime + ? new Date(toolCall.endTime).getTime() + : toolStartTime + (toolCall.duration || 0) + + children.push({ + id: `${spanId}-tool-${index}`, + name: toolCall.name, + type: 'tool', + duration: toolCall.duration || toolEndTime - toolStartTime, + startTime: new Date(toolStartTime).toISOString(), + endTime: new Date(toolEndTime).toISOString(), + status: toolCall.error ? ('error' as const) : ('success' as const), + input: toolCall.input, + output: toolCall.error + ? { error: toolCall.error, ...(toolCall.output || {}) } + : toolCall.output, + } as TraceSpan) + }) + } - const isExpandable = !isFirstSpan && hasInlineContent + // Add regular children + if (span.children && span.children.length > 0) { + children.push(...span.children) + } - return ( - <> -
- setIsCardExpanded((prev) => !prev)} - /> + // Sort by start time + return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime)) + }, [span, spanId, spanStartTime]) - - - {/* For workflow blocks, keep children nested within the card (not as separate cards) */} - {!isFirstSpan && isWorkflowBlock && inlineChildren.length > 0 && isCardExpanded && ( -
- {inlineChildren.map((childSpan, index) => ( - - ))} -
- )} + const hasChildren = allChildren.length > 0 + const isExpanded = isRootWorkflow || expandedNodes.has(spanId) + const isToggleable = !isRootWorkflow - {/* For non-workflow blocks, render inline children/tool calls */} - {!isFirstSpan && !isWorkflowBlock && isCardExpanded && ( -
- {[...toolCallSpans, ...inlineChildren].map((childSpan, index) => { - const childId = childSpan.id || `${spanId}-inline-${index}` - const childIsError = childSpan.status === 'error' - const childLowerType = childSpan.type?.toLowerCase() || '' - const hasNestedChildren = Boolean(childSpan.children && childSpan.children.length > 0) - const isNestedExpanded = expandedChildren.has(childId) - const showChildrenInProgressBar = - isIterationType(childLowerType) || childLowerType === 'workflow' - const { icon: ChildIcon, bgColor: childBgColor } = getBlockIconAndColor( - childSpan.type, - childSpan.name - ) - - return ( -
- handleChildrenToggle(childId)} - /> + const hasInput = Boolean(span.input) + const hasOutput = Boolean(span.output) - + // For progress bar - show child segments for workflow/iteration types + const lowerType = span.type?.toLowerCase() || '' + const showChildrenInProgressBar = + isIterationType(lowerType) || lowerType === 'workflow' || lowerType === 'workflow_input' - {childSpan.input && ( - - )} - - {childSpan.input && childSpan.output && ( -
- )} - - {childSpan.output && ( - - )} - - {/* Nested children */} - {showChildrenInProgressBar && hasNestedChildren && isNestedExpanded && ( -
- {childSpan.children!.map((nestedChild, nestedIndex) => ( - - ))} -
- )} -
- ) - })} -
+ return ( +
+ {/* Node Header Row */} +
onToggleNode(spanId) : undefined} + onKeyDown={ + isToggleable + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggleNode(spanId) + } + } + : undefined + } + role={isToggleable ? 'button' : undefined} + tabIndex={isToggleable ? 0 : undefined} + aria-expanded={isToggleable ? isExpanded : undefined} + aria-label={isToggleable ? (isExpanded ? 'Collapse' : 'Expand') : undefined} + > +
+ {!isIterationType(span.type) && ( +
+ {BlockIcon && } +
+ )} + + {span.name} + + {isToggleable && ( + + )} +
+ + {formatDuration(duration, { precision: 2 })} +
- {/* For the first span (workflow execution), render all children as separate top-level cards */} - {isFirstSpan && - hasChildren && - span.children!.map((childSpan, index) => ( - + {/* Progress Bar */} + - ))} - - {!isFirstSpan && - otherChildren.map((childSpan, index) => ( - - ))} - + + {/* Input/Output Sections */} + {(hasInput || hasOutput) && ( +
+ {hasInput && ( + + )} + + {hasInput && hasOutput && ( +
+ )} + + {hasOutput && ( + + )} +
+ )} + + {/* Nested Children */} + {hasChildren && ( +
+ {allChildren.map((child, index) => ( +
+ +
+ ))} +
+ )} +
+ )} +
) }) /** - * Displays workflow execution trace spans with nested structure. + * Displays workflow execution trace spans with nested tree structure. * Memoized to prevent re-renders when parent LogDetails updates. */ -export const TraceSpans = memo(function TraceSpans({ - traceSpans, - totalDuration = 0, -}: TraceSpansProps) { +export const TraceSpans = memo(function TraceSpans({ traceSpans }: TraceSpansProps) { + const [expandedNodes, setExpandedNodes] = useState>(() => new Set()) + const [expandedSections, setExpandedSections] = useState>(new Set()) + const toggleSet = useSetToggle() + const { workflowStartTime, actualTotalDuration, normalizedSpans } = useMemo(() => { if (!traceSpans || traceSpans.length === 0) { - return { workflowStartTime: 0, actualTotalDuration: totalDuration, normalizedSpans: [] } + return { workflowStartTime: 0, actualTotalDuration: 0, normalizedSpans: [] } } let earliest = Number.POSITIVE_INFINITY @@ -955,26 +736,37 @@ export const TraceSpans = memo(function TraceSpans({ actualTotalDuration: latest - earliest, normalizedSpans: normalizeAndSortSpans(traceSpans), } - }, [traceSpans, totalDuration]) + }, [traceSpans]) + + const handleToggleNode = useCallback( + (nodeId: string) => toggleSet(setExpandedNodes, nodeId), + [toggleSet] + ) + + const handleToggleSection = useCallback( + (section: string) => toggleSet(setExpandedSections, section), + [toggleSet] + ) if (!traceSpans || traceSpans.length === 0) { return
No trace data available
} return ( -
- Trace Span -
- {normalizedSpans.map((span, index) => ( - - ))} -
+
+ {normalizedSpans.map((span, index) => ( + + ))}
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 08643c051d..38f9317a1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -1,10 +1,23 @@ 'use client' -import { memo, useEffect, useMemo, useRef, useState } from 'react' -import { ChevronUp, X } from 'lucide-react' -import { Button, Eye } from '@/components/emcn' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react' +import { createPortal } from 'react-dom' +import { + Button, + Code, + Eye, + Input, + Popover, + PopoverAnchor, + PopoverContent, + PopoverDivider, + PopoverItem, + Tooltip, +} from '@/components/emcn' import { ScrollArea } from '@/components/ui/scroll-area' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' +import { cn } from '@/lib/core/utils/cn' import { ExecutionSnapshot, FileCards, @@ -17,11 +30,194 @@ import { StatusBadge, TriggerBadge, } from '@/app/workspace/[workspaceId]/logs/utils' +import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import { usePermissionConfig } from '@/hooks/use-permission-config' import { formatCost } from '@/providers/utils' import type { WorkflowLog } from '@/stores/logs/filters/types' import { useLogDetailsUIStore } from '@/stores/logs/store' +/** + * Workflow Output section with code viewer, copy, search, and context menu functionality + */ +function WorkflowOutputSection({ output }: { output: Record }) { + const contentRef = useRef(null) + const [copied, setCopied] = useState(false) + + // Context menu state + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) + + const { + isSearchActive, + searchQuery, + setSearchQuery, + matchCount, + currentMatchIndex, + activateSearch, + closeSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef, + } = useCodeViewerFeatures({ contentRef }) + + const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output]) + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setIsContextMenuOpen(true) + }, []) + + const closeContextMenu = useCallback(() => { + setIsContextMenuOpen(false) + }, []) + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(jsonString) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + closeContextMenu() + }, [jsonString, closeContextMenu]) + + const handleSearch = useCallback(() => { + activateSearch() + closeContextMenu() + }, [activateSearch, closeContextMenu]) + + return ( +
+
+ + {/* Glass action buttons overlay */} + {!isSearchActive && ( +
+ + + + + {copied ? 'Copied' : 'Copy'} + + + + + + Search + +
+ )} +
+ + {/* Search Overlay */} + {isSearchActive && ( +
e.stopPropagation()} + > + setSearchQuery(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}` : '0/0'} + + + + +
+ )} + + {/* Context Menu - rendered in portal to avoid transform/overflow clipping */} + {typeof document !== 'undefined' && + createPortal( + + + + Copy + + Search + + , + document.body + )} +
+ ) +} + interface LogDetailsProps { /** The log to display details for */ log: WorkflowLog | null @@ -78,6 +274,18 @@ export const LogDetails = memo(function LogDetails({ return isWorkflowExecutionLog && log?.cost }, [log, isWorkflowExecutionLog]) + // Extract and clean the workflow final output (remove childTraceSpans for cleaner display) + const workflowOutput = useMemo(() => { + const executionData = log?.executionData as + | { finalOutput?: Record } + | undefined + if (!executionData?.finalOutput) return null + const { childTraceSpans, ...cleanOutput } = executionData.finalOutput as { + childTraceSpans?: unknown + } & Record + return cleanOutput + }, [log?.executionData]) + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isOpen) { @@ -87,12 +295,12 @@ export const LogDetails = memo(function LogDetails({ if (isOpen) { if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) { e.preventDefault() - handleNavigate(onNavigatePrev) + onNavigatePrev() } if (e.key === 'ArrowDown' && hasNext && onNavigateNext) { e.preventDefault() - handleNavigate(onNavigateNext) + onNavigateNext() } } } @@ -101,10 +309,6 @@ export const LogDetails = memo(function LogDetails({ return () => window.removeEventListener('keydown', handleKeyDown) }, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext]) - const handleNavigate = (navigateFunction: () => void) => { - navigateFunction() - } - const formattedTimestamp = useMemo( () => (log ? formatDate(log.createdAt) : null), [log?.createdAt] @@ -142,7 +346,7 @@ export const LogDetails = memo(function LogDetails({ + View Snapshot + + +
+ )} + + {/* Workflow Output */} + {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && ( +
+ + Workflow Output + +
)} @@ -287,10 +507,12 @@ export const LogDetails = memo(function LogDetails({ {isWorkflowExecutionLog && log.executionData?.traceSpans && !permissionConfig.hideTraceSpans && ( - +
+ + Trace Span + + +
)} {/* Files */}