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 35d9bbcc6f..db01dff5e8 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 @@ -2,22 +2,47 @@ import type React from 'react' import { useCallback, useMemo, useState } from 'react' -import { highlight, languages } from 'prismjs' -import 'prismjs/components/prism-json' import clsx from 'clsx' -import { Button, ChevronDown } from '@/components/emcn' -import type { TraceSpan } from '@/stores/logs/filters/types' -import '@/components/emcn/components/code/code.css' +import { ChevronDown, Code } from '@/components/emcn' import { WorkflowIcon } from '@/components/icons' import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { getBlock, getBlockByToolName } from '@/blocks' +import type { TraceSpan } from '@/stores/logs/filters/types' interface TraceSpansProps { traceSpans?: TraceSpan[] totalDuration?: number } +/** + * Checks if a span type is a loop or parallel iteration + */ +function isIterationType(type: string): boolean { + const lower = type?.toLowerCase() || '' + return lower === 'loop-iteration' || lower === 'parallel-iteration' +} + +/** + * Creates a toggle handler for Set-based state + */ +function useSetToggle() { + return useCallback( + (setter: React.Dispatch>>, key: T) => { + setter((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + }, + [] + ) +} + /** * Generates a unique key for a trace span */ @@ -53,45 +78,56 @@ function mergeTraceSpanChildren(...groups: TraceSpan[][]): TraceSpan[] { } /** - * Normalizes a trace span by merging children from both the children array - * and any childTraceSpans in the output + * Parses a time value to milliseconds */ -function normalizeChildWorkflowSpan(span: TraceSpan): TraceSpan { - const enrichedSpan: TraceSpan = { ...span } +function parseTime(value?: string | number | null): number { + if (!value) return 0 + const ms = typeof value === 'number' ? value : new Date(value).getTime() + return Number.isFinite(ms) ? ms : 0 +} - if (enrichedSpan.output && typeof enrichedSpan.output === 'object') { - enrichedSpan.output = { ...enrichedSpan.output } - } +/** + * Normalizes and sorts trace spans recursively. + * Merges children from both span.children and span.output.childTraceSpans, + * deduplicates them, and sorts by start time. + */ +function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] { + return spans + .map((span) => { + const enrichedSpan: TraceSpan = { ...span } + + // Clean output by removing childTraceSpans after extracting + if (enrichedSpan.output && typeof enrichedSpan.output === 'object') { + enrichedSpan.output = { ...enrichedSpan.output } + if ('childTraceSpans' in enrichedSpan.output) { + const { childTraceSpans, ...cleanOutput } = enrichedSpan.output as { + childTraceSpans?: TraceSpan[] + } & Record + enrichedSpan.output = cleanOutput + } + } - const normalizedChildren = Array.isArray(span.children) - ? span.children.map((childSpan) => normalizeChildWorkflowSpan(childSpan)) - : [] - - const outputChildSpans = Array.isArray(span.output?.childTraceSpans) - ? (span.output!.childTraceSpans as TraceSpan[]).map((childSpan) => - normalizeChildWorkflowSpan(childSpan) - ) - : [] - - const mergedChildren = mergeTraceSpanChildren(normalizedChildren, outputChildSpans) - - if ( - enrichedSpan.output && - typeof enrichedSpan.output === 'object' && - enrichedSpan.output !== null && - 'childTraceSpans' in enrichedSpan.output - ) { - const { childTraceSpans, ...cleanOutput } = enrichedSpan.output as { - childTraceSpans?: TraceSpan[] - } & Record - enrichedSpan.output = cleanOutput - } + // Merge and deduplicate children from both sources + const directChildren = Array.isArray(span.children) ? span.children : [] + const outputChildren = Array.isArray(span.output?.childTraceSpans) + ? (span.output!.childTraceSpans as TraceSpan[]) + : [] - enrichedSpan.children = mergedChildren.length > 0 ? mergedChildren : undefined + const mergedChildren = mergeTraceSpanChildren(directChildren, outputChildren) + enrichedSpan.children = + mergedChildren.length > 0 ? normalizeAndSortSpans(mergedChildren) : undefined - return enrichedSpan + return enrichedSpan + }) + .sort((a, b) => { + const startDiff = parseTime(a.startTime) - parseTime(b.startTime) + if (startDiff !== 0) return startDiff + return parseTime(a.endTime) - parseTime(b.endTime) + }) } +const DEFAULT_BLOCK_COLOR = '#6b7280' + /** * Formats duration in ms */ @@ -101,48 +137,26 @@ function formatDuration(ms: number): string { } /** - * Gets color for block type + * Gets icon and color for a span type using block config */ -function getBlockColor(type: string): string { - switch (type.toLowerCase()) { - case 'agent': - return 'var(--brand-primary-hover-hex)' - case 'model': - return 'var(--brand-primary-hover-hex)' - case 'function': - return '#FF402F' - case 'tool': - return '#f97316' - case 'router': - return '#2FA1FF' - case 'condition': - return '#FF972F' - case 'evaluator': - return '#2FA1FF' - case 'api': - return '#2F55FF' - case 'loop': - case 'loop-iteration': - return '#2FB3FF' - case 'parallel': - case 'parallel-iteration': - return '#FEE12B' - case 'workflow': - return '#705335' - default: - return '#6b7280' - } -} - -/** - * Gets icon and color for block type - */ -function getBlockIconAndColor(type: string): { +function getBlockIconAndColor( + type: string, + toolName?: string +): { icon: React.ComponentType<{ className?: string }> | null bgColor: string } { const lowerType = type.toLowerCase() + // Check for tool by name first (most specific) + if (lowerType === 'tool' && toolName) { + const toolBlock = getBlockByToolName(toolName) + if (toolBlock) { + return { icon: toolBlock.icon, bgColor: toolBlock.bgColor } + } + } + + // Special types not in block registry if (lowerType === 'loop' || lowerType === 'loop-iteration') { return { icon: LoopTool.icon, bgColor: LoopTool.bgColor } } @@ -153,13 +167,14 @@ function getBlockIconAndColor(type: string): { return { icon: WorkflowIcon, bgColor: '#705335' } } + // Look up from block registry (model maps to agent) const blockType = lowerType === 'model' ? 'agent' : lowerType const blockConfig = getBlock(blockType) if (blockConfig) { return { icon: blockConfig.icon, bgColor: blockConfig.bgColor } } - return { icon: null, bgColor: getBlockColor(type) } + return { icon: null, bgColor: DEFAULT_BLOCK_COLOR } } /** @@ -177,53 +192,27 @@ function ProgressBar({ totalDuration: number }) { const segments = useMemo(() => { - if (!childSpans || childSpans.length === 0) { - const startMs = new Date(span.startTime).getTime() - const endMs = new Date(span.endTime).getTime() - const duration = endMs - startMs - const startPercent = - totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0 - const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0 - - let color = getBlockColor(span.type) - if (span.type?.toLowerCase() === 'tool' && span.name) { - const toolBlock = getBlockByToolName(span.name) - if (toolBlock?.bgColor) { - color = toolBlock.bgColor - } - } - - return [ - { - startPercent: Math.max(0, Math.min(100, startPercent)), - widthPercent: Math.max(0.5, Math.min(100, widthPercent)), - color, - }, - ] - } - - return childSpans.map((child) => { - const startMs = new Date(child.startTime).getTime() - const endMs = new Date(child.endTime).getTime() + const computeSegment = (s: TraceSpan) => { + const startMs = new Date(s.startTime).getTime() + const endMs = new Date(s.endTime).getTime() const duration = endMs - startMs const startPercent = totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0 const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0 - - let color = getBlockColor(child.type) - if (child.type?.toLowerCase() === 'tool' && child.name) { - const toolBlock = getBlockByToolName(child.name) - if (toolBlock?.bgColor) { - color = toolBlock.bgColor - } - } + const { bgColor } = getBlockIconAndColor(s.type, s.name) return { startPercent: Math.max(0, Math.min(100, startPercent)), widthPercent: Math.max(0.5, Math.min(100, widthPercent)), - color, + color: bgColor, } - }) + } + + if (!childSpans || childSpans.length === 0) { + return [computeSegment(span)] + } + + return childSpans.map(computeSegment) }, [span, childSpans, workflowStartTime, totalDuration]) return ( @@ -243,6 +232,143 @@ function ProgressBar({ ) } +interface ExpandableRowHeaderProps { + name: string + duration: number + isError: boolean + isExpanded: boolean + hasChildren: boolean + showIcon: boolean + icon: React.ComponentType<{ className?: string }> | null + bgColor: string + onToggle: () => void +} + +/** + * Reusable expandable row header with chevron, icon, name, and duration + */ +function ExpandableRowHeader({ + name, + duration, + isError, + isExpanded, + hasChildren, + showIcon, + icon: Icon, + bgColor, + onToggle, +}: ExpandableRowHeaderProps) { + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + } + : undefined + } + role={hasChildren ? 'button' : undefined} + tabIndex={hasChildren ? 0 : undefined} + aria-expanded={hasChildren ? isExpanded : undefined} + aria-label={hasChildren ? (isExpanded ? 'Collapse' : 'Expand') : undefined} + > +
+ {hasChildren && ( + + )} + {showIcon && ( +
+ {Icon && } +
+ )} + + {name} + +
+ + {formatDuration(duration)} + +
+ ) +} + +interface SpanContentProps { + span: TraceSpan + spanId: string + isError: boolean + workflowStartTime: number + totalDuration: number + expandedSections: Set + onToggle: (section: string) => void +} + +/** + * Reusable component for rendering span content (progress bar + input/output sections) + */ +function SpanContent({ + span, + spanId, + isError, + workflowStartTime, + totalDuration, + expandedSections, + onToggle, +}: SpanContentProps) { + const hasInput = Boolean(span.input) + const hasOutput = Boolean(span.output) + + return ( + <> + + + {hasInput && ( + + )} + + {hasInput && hasOutput &&
} + + {hasOutput && ( + + )} + + ) +} + /** * Renders input/output section with collapsible content */ @@ -271,51 +397,48 @@ function InputOutputSection({ return JSON.stringify(data, null, 2) }, [data]) - const highlightedCode = useMemo(() => { - if (!jsonString) return '' - return highlight(jsonString, languages.json, 'json') - }, [jsonString]) - return (
-
+
onToggle(sectionKey)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle(sectionKey) + } + }} + role='button' + tabIndex={0} + aria-expanded={isExpanded} + aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${label.toLowerCase()}`} + > {label} - +
{isExpanded && ( -
- {isError && typeof data === 'object' && data !== null && 'error' in data ? ( -
-
Error
-
- {(data as { error: string }).error} -
-
- ) : ( -
-
-            
- )} -
+ )}
) @@ -329,6 +452,8 @@ interface NestedBlockItemProps { onToggle: (section: string) => void workflowStartTime: number totalDuration: number + expandedChildren: Set + onToggleChildren: (spanId: string) => void } /** @@ -342,78 +467,43 @@ function NestedBlockItem({ onToggle, workflowStartTime, totalDuration, + expandedChildren, + onToggleChildren, }: NestedBlockItemProps): React.ReactNode { const spanId = span.id || `${parentId}-nested-${index}` const isError = span.status === 'error' - const toolBlock = - span.type?.toLowerCase() === 'tool' && span.name ? getBlockByToolName(span.name) : null - const { icon: SpanIcon, bgColor } = toolBlock - ? { icon: toolBlock.icon, bgColor: toolBlock.bgColor } - : getBlockIconAndColor(span.type) + const { icon: SpanIcon, bgColor } = getBlockIconAndColor(span.type, span.name) + const hasChildren = Boolean(span.children && span.children.length > 0) + const isChildrenExpanded = expandedChildren.has(spanId) return (
-
-
-
- {SpanIcon && } -
- - {span.name} - -
- - {formatDuration(span.duration || 0)} - -
+ onToggleChildren(spanId)} + /> - - {span.input && ( - - )} - - {span.input && span.output && ( -
- )} - - {span.output && ( - - )} - - {/* Recursively render children */} - {span.children && span.children.length > 0 && ( -
- {span.children.map((child, childIndex) => ( + {/* Nested children */} + {hasChildren && isChildrenExpanded && ( +
+ {span.children!.map((child, childIndex) => ( ))}
@@ -435,8 +527,6 @@ interface TraceSpanItemProps { span: TraceSpan totalDuration: number workflowStartTime: number - onToggle: (spanId: string, expanded: boolean) => void - expandedSpans: Set isFirstSpan?: boolean } @@ -447,21 +537,20 @@ function TraceSpanItem({ span, totalDuration, workflowStartTime, - onToggle, - expandedSpans, 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() 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 = span.children && span.children.length > 0 - const hasToolCalls = span.toolCalls && span.toolCalls.length > 0 - const hasInput = Boolean(span.input) - const hasOutput = Boolean(span.output) + 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([ @@ -473,7 +562,7 @@ function TraceSpanItem({ ]) // For workflow-in-workflow blocks, all children should be rendered inline/nested - const isWorkflowBlock = span.type?.toLowerCase() === 'workflow' + const isWorkflowBlock = span.type?.toLowerCase().includes('workflow') const inlineChildren = isWorkflowBlock ? span.children || [] : span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || [] @@ -507,138 +596,103 @@ function TraceSpanItem({ }) }, [hasToolCalls, span.toolCalls, spanId, spanStartTime]) - const handleSectionToggle = (section: string) => { - setExpandedSections((prev) => { - const next = new Set(prev) - if (next.has(section)) { - next.delete(section) - } else { - next.add(section) - } - return next - }) - } + const handleSectionToggle = useCallback( + (section: string) => toggleSet(setExpandedSections, section), + [toggleSet] + ) + + const handleChildrenToggle = useCallback( + (childSpanId: string) => toggleSet(setExpandedChildren, childSpanId), + [toggleSet] + ) + + const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name) - const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type) + // Check if this card has expandable inline content + const hasInlineContent = + (isWorkflowBlock && inlineChildren.length > 0) || + (!isWorkflowBlock && (toolCallSpans.length > 0 || inlineChildren.length > 0)) + + const isExpandable = !isFirstSpan && hasInlineContent return ( <>
-
-
- {!isFirstSpan && ( -
- {BlockIcon && } -
- )} - - {span.name} - -
- - {formatDuration(duration)} - -
+ setIsCardExpanded((prev) => !prev)} + /> - - {hasInput && ( - - )} - - {hasInput && hasOutput &&
} - - {hasOutput && ( - + {/* For workflow blocks, keep children nested within the card (not as separate cards) */} + {!isFirstSpan && isWorkflowBlock && inlineChildren.length > 0 && isCardExpanded && ( +
+ {inlineChildren.map((childSpan, index) => ( + + ))} +
)} - {(hasToolCalls || inlineChildren.length > 0) && - [...toolCallSpans, ...inlineChildren].map((childSpan, index) => { - const childId = childSpan.id || `${spanId}-inline-${index}` - const childIsError = childSpan.status === 'error' - const isInitialResponse = (childSpan.name || '') - .toLowerCase() - .includes('initial response') - - const shouldRenderSeparator = - index === 0 && (hasInput || hasOutput) && !isInitialResponse - - const toolBlock = - childSpan.type?.toLowerCase() === 'tool' && childSpan.name - ? getBlockByToolName(childSpan.name) - : null - const { icon: ChildIcon, bgColor: childBgColor } = toolBlock - ? { icon: toolBlock.icon, bgColor: toolBlock.bgColor } - : getBlockIconAndColor(childSpan.type) - - return ( -
- {shouldRenderSeparator && ( -
- )} - -
-
-
-
- {ChildIcon && ( - - )} -
- - {childSpan.name} - -
- - {formatDuration(childSpan.duration || 0)} - -
+ {/* 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)} + /> 0) - ? childSpan.children - : undefined - } + childSpans={showChildrenInProgressBar ? childSpan.children : undefined} workflowStartTime={workflowStartTime} totalDuration={totalDuration} /> @@ -648,7 +702,7 @@ function TraceSpanItem({ label='Input' data={childSpan.input} isError={false} - spanId={`${childId}-input`} + spanId={childId} sectionType='input' expandedSections={expandedSections} onToggle={handleSectionToggle} @@ -664,55 +718,62 @@ function TraceSpanItem({ label={childIsError ? 'Error' : 'Output'} data={childSpan.output} isError={childIsError} - spanId={`${childId}-output`} + spanId={childId} sectionType='output' expandedSections={expandedSections} onToggle={handleSectionToggle} /> )} - {/* Render nested blocks for loop/parallel iterations, nested workflows, and workflow block children */} - {(childSpan.type?.toLowerCase() === 'loop-iteration' || - childSpan.type?.toLowerCase() === 'parallel-iteration' || - childSpan.type?.toLowerCase() === 'workflow' || - isWorkflowBlock) && - childSpan.children && - childSpan.children.length > 0 && ( -
- {childSpan.children.map((nestedChild, nestedIndex) => ( - - ))} -
- )} + {/* Nested children */} + {showChildrenInProgressBar && hasNestedChildren && isNestedExpanded && ( +
+ {childSpan.children!.map((nestedChild, nestedIndex) => ( + + ))} +
+ )}
-
- ) - })} + ) + })} +
+ )}
- {otherChildren.map((childSpan, index) => { - const enrichedChildSpan = normalizeChildWorkflowSpan(childSpan) - return ( + {/* For the first span (workflow execution), render all children as separate top-level cards */} + {isFirstSpan && + hasChildren && + span.children!.map((childSpan, index) => ( + + ))} + + {!isFirstSpan && + otherChildren.map((childSpan, index) => ( - ) - })} + ))} ) } @@ -721,48 +782,27 @@ function TraceSpanItem({ * Displays workflow execution trace spans with nested structure */ export function TraceSpans({ traceSpans, totalDuration = 0 }: TraceSpansProps) { - const [expandedSpans, setExpandedSpans] = useState>(new Set()) - - const workflowStartTime = useMemo(() => { - if (!traceSpans || traceSpans.length === 0) return 0 - return traceSpans.reduce((earliest, span) => { - const startTime = new Date(span.startTime).getTime() - return startTime < earliest ? startTime : earliest - }, Number.POSITIVE_INFINITY) - }, [traceSpans]) - - const workflowEndTime = useMemo(() => { - if (!traceSpans || traceSpans.length === 0) return 0 - return traceSpans.reduce((latest, span) => { - const endTime = span.endTime ? new Date(span.endTime).getTime() : 0 - return endTime > latest ? endTime : latest - }, 0) - }, [traceSpans]) - - const actualTotalDuration = workflowEndTime - workflowStartTime - - const handleSpanToggle = useCallback((spanId: string, expanded: boolean) => { - setExpandedSpans((prev) => { - const newExpandedSpans = new Set(prev) - if (expanded) { - newExpandedSpans.add(spanId) - } else { - newExpandedSpans.delete(spanId) - } - return newExpandedSpans - }) - }, []) - - const filtered = useMemo(() => { - const filterTree = (spans: TraceSpan[]): TraceSpan[] => - spans - .map((s) => normalizeChildWorkflowSpan(s)) - .map((s) => ({ - ...s, - children: s.children ? filterTree(s.children) : undefined, - })) - return traceSpans ? filterTree(traceSpans) : [] - }, [traceSpans]) + const { workflowStartTime, actualTotalDuration, normalizedSpans } = useMemo(() => { + if (!traceSpans || traceSpans.length === 0) { + return { workflowStartTime: 0, actualTotalDuration: totalDuration, normalizedSpans: [] } + } + + let earliest = Number.POSITIVE_INFINITY + let latest = 0 + + for (const span of traceSpans) { + const start = parseTime(span.startTime) + const end = parseTime(span.endTime) + if (start < earliest) earliest = start + if (end > latest) latest = end + } + + return { + workflowStartTime: earliest, + actualTotalDuration: latest - earliest, + normalizedSpans: normalizeAndSortSpans(traceSpans), + } + }, [traceSpans, totalDuration]) if (!traceSpans || traceSpans.length === 0) { return
No trace data available
@@ -772,14 +812,12 @@ export function TraceSpans({ traceSpans, totalDuration = 0 }: TraceSpansProps) {
Trace Span
- {filtered.map((span, index) => ( + {normalizedSpans.map((span, index) => ( ))} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-details-resize.ts b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-details-resize.ts index a05cd83c44..4d549a3cbe 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-details-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-details-resize.ts @@ -1,9 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import { - MAX_LOG_DETAILS_WIDTH, - MIN_LOG_DETAILS_WIDTH, - useLogDetailsUIStore, -} from '@/stores/logs/store' +import { MIN_LOG_DETAILS_WIDTH, useLogDetailsUIStore } from '@/stores/logs/store' /** * Hook for handling log details panel resize via mouse drag. @@ -29,10 +25,8 @@ export function useLogDetailsResize() { const handleMouseMove = (e: MouseEvent) => { // Calculate new width from right edge of window const newWidth = window.innerWidth - e.clientX - const clampedWidth = Math.max( - MIN_LOG_DETAILS_WIDTH, - Math.min(newWidth, MAX_LOG_DETAILS_WIDTH) - ) + const maxWidth = window.innerWidth * 0.5 // 50vw + const clampedWidth = Math.max(MIN_LOG_DETAILS_WIDTH, Math.min(newWidth, maxWidth)) setPanelWidth(clampedWidth) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 6f8ffdea21..71e7b257a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -107,21 +107,42 @@ export default function Logs() { } }, [debouncedSearchQuery, setStoreSearchQuery]) + // Track previous log state for efficient change detection + const prevSelectedLogRef = useRef(null) + // Sync selected log with refreshed data from logs list useEffect(() => { if (!selectedLog?.id || logs.length === 0) return const updatedLog = logs.find((l) => l.id === selectedLog.id) - if (updatedLog) { - // Update selectedLog with fresh data from the list + if (!updatedLog) return + + const prevLog = prevSelectedLogRef.current + + // Check if status-related fields have changed (e.g., running -> done) + const hasStatusChange = + prevLog?.id === updatedLog.id && + (updatedLog.duration !== prevLog.duration || + updatedLog.level !== prevLog.level || + updatedLog.hasPendingPause !== prevLog.hasPendingPause) + + // Only update if the log data actually changed + if (updatedLog !== selectedLog) { setSelectedLog(updatedLog) - // Update index in case position changed - const newIndex = logs.findIndex((l) => l.id === selectedLog.id) - if (newIndex !== selectedLogIndex) { - setSelectedLogIndex(newIndex) - } + prevSelectedLogRef.current = updatedLog + } + + // Update index in case position changed + const newIndex = logs.findIndex((l) => l.id === selectedLog.id) + if (newIndex !== selectedLogIndex) { + setSelectedLogIndex(newIndex) + } + + // Refetch log details if status changed to keep details panel in sync + if (hasStatusChange) { + logDetailQuery.refetch() } - }, [logs, selectedLog?.id, selectedLogIndex]) + }, [logs, selectedLog?.id, selectedLogIndex, logDetailQuery.refetch]) // Refetch log details during live mode useEffect(() => { @@ -143,6 +164,7 @@ export default function Logs() { // Otherwise, select the log and open the sidebar setSelectedLog(log) + prevSelectedLogRef.current = log const index = logs.findIndex((l) => l.id === log.id) setSelectedLogIndex(index) setIsSidebarOpen(true) @@ -154,6 +176,7 @@ export default function Logs() { setSelectedLogIndex(nextIndex) const nextLog = logs[nextIndex] setSelectedLog(nextLog) + prevSelectedLogRef.current = nextLog } }, [selectedLogIndex, logs]) @@ -163,6 +186,7 @@ export default function Logs() { setSelectedLogIndex(prevIndex) const prevLog = logs[prevIndex] setSelectedLog(prevLog) + prevSelectedLogRef.current = prevLog } }, [selectedLogIndex, logs]) @@ -170,6 +194,7 @@ export default function Logs() { setIsSidebarOpen(false) setSelectedLog(null) setSelectedLogIndex(-1) + prevSelectedLogRef.current = null } useEffect(() => { @@ -332,6 +357,7 @@ export default function Logs() { e.preventDefault() setSelectedLogIndex(0) setSelectedLog(logs[0]) + prevSelectedLogRef.current = logs[0] return } diff --git a/apps/sim/stores/logs/store.ts b/apps/sim/stores/logs/store.ts index 3fa3514396..e7b5baef46 100644 --- a/apps/sim/stores/logs/store.ts +++ b/apps/sim/stores/logs/store.ts @@ -5,9 +5,15 @@ import { persist } from 'zustand/middleware' * Width constraints for the log details panel. */ export const MIN_LOG_DETAILS_WIDTH = 340 -export const MAX_LOG_DETAILS_WIDTH = 700 export const DEFAULT_LOG_DETAILS_WIDTH = 340 +/** + * Returns the maximum log details panel width (50vw). + * Falls back to a reasonable default for SSR. + */ +export const getMaxLogDetailsWidth = () => + typeof window !== 'undefined' ? window.innerWidth * 0.5 : 800 + /** * Log details UI state persisted across sessions. */ @@ -27,7 +33,8 @@ export const useLogDetailsUIStore = create()( * @param width - Desired width in pixels for the panel. */ setPanelWidth: (width) => { - const clampedWidth = Math.max(MIN_LOG_DETAILS_WIDTH, Math.min(width, MAX_LOG_DETAILS_WIDTH)) + const maxWidth = getMaxLogDetailsWidth() + const clampedWidth = Math.max(MIN_LOG_DETAILS_WIDTH, Math.min(width, maxWidth)) set({ panelWidth: clampedWidth }) }, isResizing: false,