@@ -36,8 +36,9 @@ import { getBlock, getBlockByToolName } from '@/blocks'
3636import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
3737
3838const DEFAULT_BLOCK_COLOR = '#6b7280'
39- const TREE_PANE_WIDTH = 240
39+ const TREE_PANE_WIDTH = 300
4040const INDENT_PX = 12
41+ const MIN_BAR_PCT = 0.5
4142
4243interface TraceViewProps {
4344 traceSpans : TraceSpan [ ]
@@ -47,6 +48,7 @@ interface FlatSpanEntry {
4748 span : TraceSpan
4849 depth : number
4950 parentIds : string [ ]
51+ parentDuration ?: number
5052}
5153
5254interface BlockAppearance {
@@ -186,21 +188,28 @@ function formatTps(outputTokens: number | undefined, durationMs: number): string
186188
187189/**
188190 * Flattens the visible (expanded) span tree into a linear list for keyboard
189- * navigation, carrying depth and the chain of parent ids for indent drawing.
191+ * navigation, carrying depth, the chain of parent ids for indent drawing, and
192+ * the immediate parent's duration for percentage-of-parent calculations.
190193 */
191194function flattenVisible ( spans : TraceSpan [ ] , expanded : Set < string > ) : FlatSpanEntry [ ] {
192195 const out : FlatSpanEntry [ ] = [ ]
193- const walk = ( list : TraceSpan [ ] , depth : number , parents : string [ ] ) => {
196+ const walk = (
197+ list : TraceSpan [ ] ,
198+ depth : number ,
199+ parents : string [ ] ,
200+ parentDuration : number | undefined
201+ ) => {
194202 for ( const span of list ) {
195203 const id = getSpanId ( span )
196- out . push ( { span, depth, parentIds : parents } )
204+ out . push ( { span, depth, parentIds : parents , parentDuration } )
197205 const children = getDisplayChildren ( span )
198206 if ( children . length > 0 && expanded . has ( id ) ) {
199- walk ( children , depth + 1 , [ ...parents , id ] )
207+ const ownDuration = span . duration || parseTime ( span . endTime ) - parseTime ( span . startTime )
208+ walk ( children , depth + 1 , [ ...parents , id ] , ownDuration )
200209 }
201210 }
202211 }
203- walk ( spans , 0 , [ ] )
212+ walk ( spans , 0 , [ ] , undefined )
204213 return out
205214}
206215
@@ -270,8 +279,10 @@ function collectMatchingIds(spans: TraceSpan[], query: string): Set<string> {
270279}
271280
272281/**
273- * Row in the tree pane. Renders the span icon, name, duration, and indentation
274- * guides. Clicking selects the span; the chevron toggles expansion.
282+ * Row in the tree pane. Renders the span icon, name, duration, a hover tooltip
283+ * with timing context, and a Gantt-style mini timeline bar below the row so the
284+ * span's position within the run is visible at a glance. Clicking selects the
285+ * span; the chevron toggles expansion.
275286 */
276287const TraceTreeRow = memo ( function TraceTreeRow ( {
277288 entry,
@@ -281,6 +292,8 @@ const TraceTreeRow = memo(function TraceTreeRow({
281292 onSelect,
282293 onToggleExpand,
283294 matchQuery,
295+ runStartMs,
296+ runTotalMs,
284297} : {
285298 entry : FlatSpanEntry
286299 isSelected : boolean
@@ -289,22 +302,33 @@ const TraceTreeRow = memo(function TraceTreeRow({
289302 onSelect : ( id : string ) => void
290303 onToggleExpand : ( id : string ) => void
291304 matchQuery : string
305+ runStartMs : number
306+ runTotalMs : number
292307} ) {
293- const { span, depth } = entry
308+ const { span, depth, parentDuration } = entry
294309 const id = getSpanId ( span )
295- const duration = span . duration || parseTime ( span . endTime ) - parseTime ( span . startTime )
310+ const startMs = parseTime ( span . startTime )
311+ const endMs = parseTime ( span . endTime )
312+ const duration = span . duration || endMs - startMs
296313 const isRootWorkflow = depth === 0 && span . type ?. toLowerCase ( ) === 'workflow'
297314 const hasError = isRootWorkflow ? hasUnhandledErrorInTree ( span ) : hasErrorInTree ( span )
298315 const { icon : BlockIcon , bgColor } = getBlockAppearance ( span . type , span . name )
299316 const nameMatches = ! ! matchQuery && spanMatchesQuery ( span , matchQuery )
300317
318+ const offsetMs = runStartMs > 0 ? Math . max ( 0 , startMs - runStartMs ) : 0
319+ const offsetPct = runTotalMs > 0 ? Math . min ( 100 , ( offsetMs / runTotalMs ) * 100 ) : 0
320+ const rawDurationPct = runTotalMs > 0 ? ( duration / runTotalMs ) * 100 : 0
321+ const durationPct = Math . max ( MIN_BAR_PCT , Math . min ( 100 - offsetPct , rawDurationPct ) )
322+ const pctOfTotal = runTotalMs > 0 ? ( duration / runTotalMs ) * 100 : null
323+ const pctOfParent =
324+ parentDuration && parentDuration > 0 ? ( duration / parentDuration ) * 100 : null
325+
301326 return (
302327 < div
303328 className = { cn (
304- 'group relative flex min-w-0 cursor-pointer items-center gap-1.5 py-1 pr-2 transition-colors' ,
329+ 'group relative flex min-w-0 cursor-pointer flex-col transition-colors' ,
305330 isSelected ? 'bg-[var(--surface-3)]' : 'hover-hover:bg-[var(--surface-2)]'
306331 ) }
307- style = { { paddingLeft : 8 + depth * INDENT_PX } }
308332 onClick = { ( ) => onSelect ( id ) }
309333 onKeyDown = { ( e ) => {
310334 if ( e . key === 'Enter' || e . key === ' ' ) {
@@ -318,44 +342,84 @@ const TraceTreeRow = memo(function TraceTreeRow({
318342 aria-expanded = { canExpand ? isExpanded : undefined }
319343 aria-level = { depth + 1 }
320344 >
321- { canExpand ? (
322- < button
323- type = 'button'
324- className = 'flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-sm text-[var(--text-tertiary)] transition-colors hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-primary)]'
325- onClick = { ( e ) => {
326- e . stopPropagation ( )
327- onToggleExpand ( id )
328- } }
329- aria-label = { isExpanded ? 'Collapse' : 'Expand' }
330- >
331- < ChevronDown
332- className = 'h-[10px] w-[10px]'
333- style = { { transform : isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } }
345+ < div
346+ className = 'flex min-w-0 items-center gap-1.5 pt-1 pr-2'
347+ style = { { paddingLeft : 8 + depth * INDENT_PX } }
348+ >
349+ { canExpand ? (
350+ < button
351+ type = 'button'
352+ className = 'flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-sm text-[var(--text-tertiary)] transition-colors hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-primary)]'
353+ onClick = { ( e ) => {
354+ e . stopPropagation ( )
355+ onToggleExpand ( id )
356+ } }
357+ aria-label = { isExpanded ? 'Collapse' : 'Expand' }
358+ >
359+ < ChevronDown
360+ className = { cn (
361+ 'h-[10px] w-[10px] transition-transform duration-100' ,
362+ ! isExpanded && '-rotate-90'
363+ ) }
364+ />
365+ </ button >
366+ ) : (
367+ < div className = 'h-[14px] w-[14px] flex-shrink-0' />
368+ ) }
369+ { ! isIterationType ( span . type ) && (
370+ < div
371+ className = 'flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
372+ style = { { background : bgColor } }
373+ >
374+ { BlockIcon && < BlockIcon className = 'h-[9px] w-[9px] text-white' /> }
375+ </ div >
376+ ) }
377+ < Tooltip . Root >
378+ < Tooltip . Trigger asChild >
379+ < span
380+ className = { cn (
381+ 'min-w-0 flex-1 truncate font-medium text-caption' ,
382+ hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-secondary)]' ,
383+ nameMatches && 'text-[var(--text-primary)]'
384+ ) }
385+ >
386+ { span . name }
387+ </ span >
388+ </ Tooltip . Trigger >
389+ < Tooltip . Content side = 'right' className = 'max-w-[320px]' >
390+ < div className = 'flex flex-col gap-0.5' >
391+ < span className = 'font-medium' > { span . name } </ span >
392+ < span className = 'text-[var(--text-tertiary)] text-caption' >
393+ { formatDuration ( duration , { precision : 2 } ) || '—' }
394+ { offsetMs > 0 && ` · +${ formatDuration ( offsetMs , { precision : 2 } ) } ` }
395+ </ span >
396+ { pctOfTotal !== null && pctOfTotal >= 0.1 && (
397+ < span className = 'text-[var(--text-tertiary)] text-caption' >
398+ { pctOfTotal . toFixed ( pctOfTotal >= 10 ? 0 : 1 ) } % of total
399+ { pctOfParent !== null &&
400+ pctOfParent >= 0.1 &&
401+ ` · ${ pctOfParent . toFixed ( pctOfParent >= 10 ? 0 : 1 ) } % of parent` }
402+ </ span >
403+ ) }
404+ </ div >
405+ </ Tooltip . Content >
406+ </ Tooltip . Root >
407+ < span className = 'flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption tabular-nums' >
408+ { formatDuration ( duration , { precision : 2 } ) }
409+ </ span >
410+ </ div >
411+ < div className = 'pt-[3px] pr-2 pb-[5px] pl-2' >
412+ < div className = 'relative h-[3px] w-full overflow-hidden rounded-full bg-[var(--border)]' >
413+ < div
414+ className = 'absolute h-full rounded-full'
415+ style = { {
416+ left : `${ offsetPct } %` ,
417+ width : `${ durationPct } %` ,
418+ backgroundColor : hasError ? 'var(--text-error)' : bgColor ,
419+ } }
334420 />
335- </ button >
336- ) : (
337- < div className = 'h-[14px] w-[14px] flex-shrink-0' />
338- ) }
339- { ! isIterationType ( span . type ) && (
340- < div
341- className = 'flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
342- style = { { background : bgColor } }
343- >
344- { BlockIcon && < BlockIcon className = 'h-[9px] w-[9px] text-white' /> }
345421 </ div >
346- ) }
347- < span
348- className = { cn (
349- 'min-w-0 flex-1 truncate font-medium text-caption' ,
350- hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-secondary)]' ,
351- nameMatches && 'text-[var(--text-primary)]'
352- ) }
353- >
354- { span . name }
355- </ span >
356- < span className = 'flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption' >
357- { formatDuration ( duration , { precision : 2 } ) }
358- </ span >
422+ </ div >
359423 </ div >
360424 )
361425} )
@@ -448,8 +512,10 @@ function DetailCodeSection({
448512 { label }
449513 </ span >
450514 < ChevronDown
451- className = 'h-[8px] w-[8px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
452- style = { { transform : isOpen ? 'rotate(180deg)' : 'rotate(0deg)' } }
515+ className = { cn (
516+ 'h-[8px] w-[8px] text-[var(--text-tertiary)] transition-colors transition-transform duration-100 group-hover:text-[var(--text-primary)]' ,
517+ ! isOpen && '-rotate-90'
518+ ) }
453519 />
454520 </ div >
455521 { isOpen && (
@@ -458,7 +524,7 @@ function DetailCodeSection({
458524 < Code . Viewer
459525 code = { jsonString }
460526 language = 'json'
461- className = '!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[360px] min-h-0 max- w-full rounded-md border-0 [word-break:break-all]'
527+ className = '!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-w-full rounded-md border-0 [word-break:break-all]'
462528 wrapText
463529 searchQuery = { isSearchActive ? searchQuery : undefined }
464530 currentMatchIndex = { currentMatchIndex }
@@ -759,36 +825,39 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
759825export const TraceView = memo ( function TraceView ( { traceSpans } : TraceViewProps ) {
760826 const containerRef = useRef < HTMLDivElement > ( null )
761827 const [ searchQuery , setSearchQuery ] = useState ( '' )
762- const [ expandedNodes , setExpandedNodes ] = useState < Set < string > > ( ( ) => new Set ( ) )
763- const [ hasInitialized , setHasInitialized ] = useState ( false )
764- const [ selectedId , setSelectedId ] = useState < string | null > ( null )
765-
766- const { normalizedSpans, allIds, totalDuration, firstRootId, blockCount } = useMemo ( ( ) => {
767- const sorted = normalizeAndSort ( traceSpans ?? [ ] )
768- let earliest = Number . POSITIVE_INFINITY
769- let latest = 0
770- for ( const span of sorted ) {
771- const s = parseTime ( span . startTime )
772- const e = parseTime ( span . endTime )
773- if ( s < earliest ) earliest = s
774- if ( e > latest ) latest = e
775- }
776- const ids = collectAllIds ( sorted )
777- const count = ids . length
778- return {
779- normalizedSpans : sorted ,
780- allIds : ids ,
781- totalDuration : latest > earliest ? latest - earliest : 0 ,
782- firstRootId : sorted . length > 0 ? getSpanId ( sorted [ 0 ] ) : null ,
783- blockCount : count ,
784- }
785- } , [ traceSpans ] )
786828
787- useEffect ( ( ) => {
829+ const { normalizedSpans, allIds, totalDuration, runStartMs, firstRootId, blockCount } =
830+ useMemo ( ( ) => {
831+ const sorted = normalizeAndSort ( traceSpans ?? [ ] )
832+ let earliest = Number . POSITIVE_INFINITY
833+ let latest = 0
834+ for ( const span of sorted ) {
835+ const s = parseTime ( span . startTime )
836+ const e = parseTime ( span . endTime )
837+ if ( s < earliest ) earliest = s
838+ if ( e > latest ) latest = e
839+ }
840+ const ids = collectAllIds ( sorted )
841+ const count = ids . length
842+ const runStart = earliest !== Number . POSITIVE_INFINITY ? earliest : 0
843+ return {
844+ normalizedSpans : sorted ,
845+ allIds : ids ,
846+ totalDuration : latest > runStart ? latest - runStart : 0 ,
847+ runStartMs : runStart ,
848+ firstRootId : sorted . length > 0 ? getSpanId ( sorted [ 0 ] ) : null ,
849+ blockCount : count ,
850+ }
851+ } , [ traceSpans ] )
852+
853+ const [ expandedNodes , setExpandedNodes ] = useState < Set < string > > ( ( ) => new Set ( allIds ) )
854+ const [ selectedId , setSelectedId ] = useState < string | null > ( firstRootId )
855+ const [ prevAllIds , setPrevAllIds ] = useState ( allIds )
856+ if ( prevAllIds !== allIds ) {
857+ setPrevAllIds ( allIds )
788858 setExpandedNodes ( new Set ( allIds ) )
789859 setSelectedId ( firstRootId )
790- setHasInitialized ( true )
791- } , [ allIds , firstRootId ] )
860+ }
792861
793862 const matchingIds = useMemo (
794863 ( ) => ( searchQuery ? collectMatchingIds ( normalizedSpans , searchQuery ) : null ) ,
@@ -894,7 +963,7 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps)
894963 >
895964 { runStatus === 'error' ? 'Error' : 'Success' }
896965 </ span >
897- < span className = 'flex-shrink-0 font-medium text-[var(--text-secondary)] text-caption' >
966+ < span className = 'flex-shrink-0 font-medium text-[var(--text-secondary)] text-caption tabular-nums ' >
898967 { formatDuration ( totalDuration , { precision : 2 } ) || '—' }
899968 </ span >
900969 < span className = 'flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption' >
@@ -949,7 +1018,7 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps)
9491018 style = { { width : TREE_PANE_WIDTH } }
9501019 role = 'tree'
9511020 >
952- { hasInitialized && flatList . length === 0 && (
1021+ { flatList . length === 0 && (
9531022 < div className = 'p-3 text-[var(--text-tertiary)] text-caption' > No matching spans</ div >
9541023 ) }
9551024 { flatList . map ( ( entry ) => {
@@ -965,6 +1034,8 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps)
9651034 onSelect = { handleSelect }
9661035 onToggleExpand = { handleToggleExpand }
9671036 matchQuery = { searchQuery }
1037+ runStartMs = { runStartMs }
1038+ runTotalMs = { totalDuration }
9681039 />
9691040 )
9701041 } ) }
0 commit comments