@@ -20,11 +20,11 @@ import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons'
2020import { AgentSkillsIcon , WorkflowIcon } from '@/components/icons'
2121import { cn } from '@/lib/core/utils/cn'
2222import { formatDuration } from '@/lib/core/utils/formatting'
23+ import type { TraceSpan } from '@/lib/logs/types'
2324import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
2425import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
2526import { getBlock , getBlockByToolName } from '@/blocks'
2627import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
27- import type { TraceSpan } from '@/stores/logs/filters/types'
2828
2929interface TraceSpansProps {
3030 traceSpans ?: TraceSpan [ ]
@@ -58,6 +58,86 @@ function useSetToggle() {
5858 )
5959}
6060
61+ /**
62+ * Formats a token count with locale-aware thousands separators.
63+ * Returns `undefined` for missing or non-positive counts so callers can
64+ * filter them out before rendering.
65+ */
66+ function formatTokenCount ( value : number | undefined ) : string | undefined {
67+ if ( typeof value !== 'number' || ! Number . isFinite ( value ) || value <= 0 ) return undefined
68+ return value . toLocaleString ( 'en-US' )
69+ }
70+
71+ /**
72+ * Builds a compact, dot-separated token summary for a span:
73+ * `"1,234 in · 567 out · 1,801 total"` with cache/reasoning appended when
74+ * present. Returns `undefined` when the span has no meaningful token data.
75+ */
76+ function formatTokensSummary ( tokens : TraceSpan [ 'tokens' ] ) : string | undefined {
77+ if ( ! tokens ) return undefined
78+ const parts : string [ ] = [ ]
79+ const input = formatTokenCount ( tokens . input )
80+ const output = formatTokenCount ( tokens . output )
81+ const total = formatTokenCount ( tokens . total )
82+ const cacheRead = formatTokenCount ( tokens . cacheRead )
83+ const cacheWrite = formatTokenCount ( tokens . cacheWrite )
84+ const reasoning = formatTokenCount ( tokens . reasoning )
85+ if ( input ) parts . push ( `${ input } in` )
86+ if ( cacheRead ) parts . push ( `${ cacheRead } cached` )
87+ if ( cacheWrite ) parts . push ( `${ cacheWrite } cache write` )
88+ if ( output ) parts . push ( `${ output } out` )
89+ if ( reasoning ) parts . push ( `${ reasoning } reasoning` )
90+ if ( total ) parts . push ( `${ total } total` )
91+ return parts . length > 0 ? parts . join ( ' · ' ) : undefined
92+ }
93+
94+ /**
95+ * Formats a USD cost value for display. Shows `<$0.0001` for non-zero sub-cent
96+ * amounts so the user sees it was counted.
97+ */
98+ function formatCostAmount ( value : number | undefined ) : string | undefined {
99+ if ( typeof value !== 'number' || ! Number . isFinite ( value ) || value <= 0 ) return undefined
100+ if ( value < 0.0001 ) return '<$0.0001'
101+ return `$${ value . toFixed ( 4 ) } `
102+ }
103+
104+ /**
105+ * Builds a compact cost summary: `"$0.0023 · $0.0001 in · $0.0022 out"`.
106+ * Falls back to whichever parts are present.
107+ */
108+ function formatCostSummary ( cost : TraceSpan [ 'cost' ] ) : string | undefined {
109+ if ( ! cost ) return undefined
110+ const parts : string [ ] = [ ]
111+ const total = formatCostAmount ( cost . total )
112+ const input = formatCostAmount ( cost . input )
113+ const output = formatCostAmount ( cost . output )
114+ if ( total ) parts . push ( total )
115+ if ( input ) parts . push ( `${ input } in` )
116+ if ( output ) parts . push ( `${ output } out` )
117+ return parts . length > 0 ? parts . join ( ' · ' ) : undefined
118+ }
119+
120+ /**
121+ * Derives tokens-per-second from output tokens over segment duration.
122+ * Returns `undefined` when inputs are missing or non-positive.
123+ */
124+ function formatTps ( outputTokens : number | undefined , durationMs : number ) : string | undefined {
125+ if ( typeof outputTokens !== 'number' || ! ( outputTokens > 0 ) ) return undefined
126+ if ( ! ( durationMs > 0 ) ) return undefined
127+ const tps = Math . round ( outputTokens / ( durationMs / 1000 ) )
128+ if ( ! ( tps > 0 ) ) return undefined
129+ return `${ tps . toLocaleString ( 'en-US' ) } tok/s`
130+ }
131+
132+ /**
133+ * Formats time-to-first-token. Uses `ms` below 1000, `s` above.
134+ */
135+ function formatTtft ( ms : number | undefined ) : string | undefined {
136+ if ( typeof ms !== 'number' || ! Number . isFinite ( ms ) || ms < 0 ) return undefined
137+ if ( ms < 1000 ) return `${ Math . round ( ms ) } ms`
138+ return `${ ( ms / 1000 ) . toFixed ( 2 ) } s`
139+ }
140+
61141/**
62142 * Parses a time value to milliseconds
63143 */
@@ -185,7 +265,7 @@ function ProgressBar({
185265 const computeSegment = ( s : TraceSpan ) => {
186266 const startMs = new Date ( s . startTime ) . getTime ( )
187267 const endMs = new Date ( s . endTime ) . getTime ( )
188- const duration = endMs - startMs
268+ const duration = s . duration || endMs - startMs
189269 const startPercent =
190270 totalDuration > 0 ? ( ( startMs - workflowStartTime ) / totalDuration ) * 100 : 0
191271 const widthPercent = totalDuration > 0 ? ( duration / totalDuration ) * 100 : 0
@@ -238,7 +318,7 @@ function InputOutputSection({
238318 data : unknown
239319 isError : boolean
240320 spanId : string
241- sectionType : 'input' | 'output'
321+ sectionType : 'input' | 'output' | 'thinking' | 'modelToolCalls' | 'errorMessage'
242322 expandedSections : Set < string >
243323 onToggle : ( section : string ) => void
244324} ) {
@@ -268,6 +348,7 @@ function InputOutputSection({
268348
269349 const jsonString = useMemo ( ( ) => {
270350 if ( ! data ) return ''
351+ if ( typeof data === 'string' ) return data
271352 return JSON . stringify ( data , null , 2 )
272353 } , [ data ] )
273354
@@ -513,63 +594,52 @@ const TraceSpanNode = memo(function TraceSpanNode({
513594
514595 const { icon : BlockIcon , bgColor } = getBlockIconAndColor ( span . type , span . name )
515596
516- // Build all children including tool calls
517- const allChildren = useMemo ( ( ) => {
518- const children : TraceSpan [ ] = [ ]
519-
520- // Add tool calls as child spans
521- if ( span . toolCalls && span . toolCalls . length > 0 ) {
522- span . toolCalls . forEach ( ( toolCall , index ) => {
523- const toolStartTime = toolCall . startTime
524- ? new Date ( toolCall . startTime ) . getTime ( )
525- : spanStartTime
526- const toolEndTime = toolCall . endTime
527- ? new Date ( toolCall . endTime ) . getTime ( )
528- : toolStartTime + ( toolCall . duration || 0 )
529-
530- children . push ( {
531- id : `${ spanId } -tool-${ index } ` ,
532- name : toolCall . name ,
597+ const displayChildren = useMemo ( ( ) => {
598+ const kids : TraceSpan [ ] = span . children ?. length
599+ ? [ ...span . children ]
600+ : ( span . toolCalls ?? [ ] ) . map ( ( tc , i ) => ( {
601+ id : `${ spanId } -tool-${ i } ` ,
602+ name : tc . name ,
533603 type : 'tool' ,
534- duration : toolCall . duration || toolEndTime - toolStartTime ,
535- startTime : new Date ( toolStartTime ) . toISOString ( ) ,
536- endTime : new Date ( toolEndTime ) . toISOString ( ) ,
537- status : toolCall . error ? ( 'error' as const ) : ( 'success' as const ) ,
538- input : toolCall . input ,
539- output : toolCall . error
540- ? { error : toolCall . error , ...( toolCall . output || { } ) }
541- : toolCall . output ,
542- } as TraceSpan )
543- } )
544- }
545-
546- // Add regular children
547- if ( span . children && span . children . length > 0 ) {
548- children . push ( ...span . children )
549- }
604+ duration : tc . duration || 0 ,
605+ startTime : tc . startTime ?? span . startTime ,
606+ endTime : tc . endTime ?? span . endTime ,
607+ status : tc . error ? ( 'error' as const ) : ( 'success' as const ) ,
608+ input : tc . input ,
609+ output : tc . error ? { error : tc . error , ...( tc . output ?? { } ) } : tc . output ,
610+ } ) )
550611
551- // Sort by start time
552- return children . sort ( ( a , b ) => parseTime ( a . startTime ) - parseTime ( b . startTime ) )
553- } , [ span , spanId , spanStartTime ] )
612+ kids . sort ( ( a , b ) => parseTime ( a . startTime ) - parseTime ( b . startTime ) )
554613
555- // Hide empty model timing segments for agents without tool calls
556- const filteredChildren = useMemo ( ( ) => {
557614 const isAgent = span . type ?. toLowerCase ( ) === 'agent'
558- const hasToolCalls =
559- ( span . toolCalls ?. length ?? 0 ) > 0 || allChildren . some ( ( c ) => c . type ?. toLowerCase ( ) === 'tool' )
560-
561- if ( isAgent && ! hasToolCalls ) {
562- return allChildren . filter ( ( c ) => c . type ?. toLowerCase ( ) !== 'model' )
615+ const hasToolCall = kids . some ( ( c ) => c . type ?. toLowerCase ( ) === 'tool' )
616+ if ( isAgent && ! hasToolCall ) {
617+ return kids . filter ( ( c ) => c . type ?. toLowerCase ( ) !== 'model' )
563618 }
564- return allChildren
565- } , [ allChildren , span . type , span . toolCalls ] )
619+ return kids
620+ } , [ span , spanId ] )
566621
567- const hasChildren = filteredChildren . length > 0
622+ const hasChildren = displayChildren . length > 0
568623 const isExpanded = isRootWorkflow || expandedNodes . has ( spanId )
569624 const isToggleable = ! isRootWorkflow
570625
571626 const hasInput = Boolean ( span . input )
572627 const hasOutput = Boolean ( span . output )
628+ const hasThinking = Boolean ( span . thinking )
629+ const hasModelToolCalls = Boolean ( span . modelToolCalls && span . modelToolCalls . length > 0 )
630+ const hasFinishReason = Boolean ( span . finishReason )
631+ const tokensSummary = formatTokensSummary ( span . tokens )
632+ const hasTokens = Boolean ( tokensSummary )
633+ const costSummary = formatCostSummary ( span . cost )
634+ const hasCost = Boolean ( costSummary )
635+ const isModelSpan = span . type ?. toLowerCase ( ) === 'model'
636+ const tpsSummary = isModelSpan ? formatTps ( span . tokens ?. output , duration ) : undefined
637+ const hasTps = Boolean ( tpsSummary )
638+ const ttftSummary = formatTtft ( span . ttft )
639+ const hasTtft = Boolean ( ttftSummary )
640+ const hasProvider = Boolean ( span . provider )
641+ const hasErrorType = Boolean ( span . errorType )
642+ const hasErrorMessage = Boolean ( span . errorMessage )
573643
574644 // For progress bar - show child segments for workflow/iteration types
575645 const lowerType = span . type ?. toLowerCase ( ) || ''
@@ -641,7 +711,18 @@ const TraceSpanNode = memo(function TraceSpanNode({
641711 />
642712
643713 { /* Input/Output Sections */ }
644- { ( hasInput || hasOutput ) && (
714+ { ( hasInput ||
715+ hasOutput ||
716+ hasThinking ||
717+ hasModelToolCalls ||
718+ hasFinishReason ||
719+ hasTokens ||
720+ hasCost ||
721+ hasTps ||
722+ hasTtft ||
723+ hasProvider ||
724+ hasErrorType ||
725+ hasErrorMessage ) && (
645726 < div className = 'flex min-w-0 flex-col gap-1.5 overflow-hidden py-0.5' >
646727 { hasInput && (
647728 < InputOutputSection
@@ -670,13 +751,125 @@ const TraceSpanNode = memo(function TraceSpanNode({
670751 onToggle = { onToggleSection }
671752 />
672753 ) }
754+
755+ { hasThinking && (
756+ < >
757+ { ( hasInput || hasOutput ) && (
758+ < div className = 'border-[var(--border)] border-t border-dashed' />
759+ ) }
760+ < InputOutputSection
761+ label = 'Thinking'
762+ data = { span . thinking }
763+ isError = { false }
764+ spanId = { spanId }
765+ sectionType = 'thinking'
766+ expandedSections = { expandedSections }
767+ onToggle = { onToggleSection }
768+ />
769+ </ >
770+ ) }
771+
772+ { hasModelToolCalls && (
773+ < >
774+ { ( hasInput || hasOutput || hasThinking ) && (
775+ < div className = 'border-[var(--border)] border-t border-dashed' />
776+ ) }
777+ < InputOutputSection
778+ label = 'Tool calls'
779+ data = { span . modelToolCalls }
780+ isError = { false }
781+ spanId = { spanId }
782+ sectionType = 'modelToolCalls'
783+ expandedSections = { expandedSections }
784+ onToggle = { onToggleSection }
785+ />
786+ </ >
787+ ) }
788+
789+ { hasErrorMessage && (
790+ < >
791+ { ( hasInput || hasOutput || hasThinking || hasModelToolCalls ) && (
792+ < div className = 'border-[var(--border)] border-t border-dashed' />
793+ ) }
794+ < InputOutputSection
795+ label = 'Error'
796+ data = { span . errorMessage }
797+ isError
798+ spanId = { spanId }
799+ sectionType = 'errorMessage'
800+ expandedSections = { expandedSections }
801+ onToggle = { onToggleSection }
802+ />
803+ </ >
804+ ) }
805+
806+ { hasErrorType && (
807+ < div className = 'flex items-center justify-between gap-2 font-medium text-caption' >
808+ < span className = 'flex-shrink-0 text-[var(--text-tertiary)]' > Error type</ span >
809+ < span className = 'min-w-0 truncate text-right text-[var(--text-error)]' >
810+ { span . errorType }
811+ </ span >
812+ </ div >
813+ ) }
814+
815+ { hasFinishReason && (
816+ < div className = 'flex items-center justify-between font-medium text-caption' >
817+ < span className = 'text-[var(--text-tertiary)]' > Finish reason</ span >
818+ < span className = 'text-[var(--text-secondary)]' > { span . finishReason } </ span >
819+ </ div >
820+ ) }
821+
822+ { hasProvider && (
823+ < div className = 'flex items-center justify-between gap-2 font-medium text-caption' >
824+ < span className = 'flex-shrink-0 text-[var(--text-tertiary)]' > Provider</ span >
825+ < span className = 'min-w-0 truncate text-right text-[var(--text-secondary)]' >
826+ { span . provider }
827+ </ span >
828+ </ div >
829+ ) }
830+
831+ { hasTtft && (
832+ < div className = 'flex items-center justify-between gap-2 font-medium text-caption' >
833+ < span className = 'flex-shrink-0 text-[var(--text-tertiary)]' > TTFT</ span >
834+ < span className = 'min-w-0 truncate text-right text-[var(--text-secondary)]' >
835+ { ttftSummary }
836+ </ span >
837+ </ div >
838+ ) }
839+
840+ { hasTokens && (
841+ < div className = 'flex items-center justify-between gap-2 font-medium text-caption' >
842+ < span className = 'flex-shrink-0 text-[var(--text-tertiary)]' > Tokens</ span >
843+ < span className = 'min-w-0 truncate text-right text-[var(--text-secondary)]' >
844+ { tokensSummary }
845+ </ span >
846+ </ div >
847+ ) }
848+
849+ { hasTps && (
850+ < div className = 'flex items-center justify-between gap-2 font-medium text-caption' >
851+ < span className = 'flex-shrink-0 text-[var(--text-tertiary)]' > Throughput</ span >
852+ < span className = 'min-w-0 truncate text-right text-[var(--text-secondary)]' >
853+ { tpsSummary }
854+ </ span >
855+ </ div >
856+ ) }
857+
858+ { hasCost && (
859+ < div className = 'flex items-center justify-between gap-2 font-medium text-caption' >
860+ < span className = 'flex-shrink-0 text-[var(--text-tertiary)]' > Cost</ span >
861+ < span className = 'min-w-0 truncate text-right text-[var(--text-secondary)]' >
862+ { costSummary }
863+ </ span >
864+ </ div >
865+ ) }
673866 </ div >
674867 ) }
675868
676869 { /* Nested Children */ }
677870 { hasChildren && (
678871 < div className = 'flex min-w-0 flex-col gap-0.5 border-[var(--border)] border-l pl-2.5' >
679- { filteredChildren . map ( ( child , index ) => (
872+ { displayChildren . map ( ( child , index ) => (
680873 < div key = { child . id || `${ spanId } -child-${ index } ` } className = 'pl-1.5' >
681874 < TraceSpanNode
682875 span = { child }
0 commit comments