fix(trace-spans): fixed small styling bugs (#1900)

* fix trace spands timeline styling and hover tooltip

* fixed invalid dom properties
This commit is contained in:
Adam Gough
2025-11-11 13:36:06 -08:00
committed by GitHub
parent 1cce486442
commit 831ce91577
2 changed files with 52 additions and 74 deletions

View File

@@ -1,4 +1,5 @@
import type React from 'react'
import { useState } from 'react'
import { ChevronDown, ChevronRight, Code, Cpu, ExternalLink } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import {
@@ -29,9 +30,8 @@ interface TraceSpanItemProps {
onToggle: (spanId: string, expanded: boolean, hasSubItems: boolean) => void
expandedSpans: Set<string>
hasSubItems?: boolean
hoveredPercent?: number | null
hoveredWorkflowMs?: number | null
forwardHover: (clientX: number, clientY: number) => void
onTimelineHover?: (clientX: number, clientY: number, rect: DOMRect) => void
onTimelineLeave?: () => void
gapBeforeMs?: number
gapBeforePercent?: number
showRelativeChip?: boolean
@@ -52,13 +52,14 @@ export function TraceSpanItem({
workflowStartTime,
onToggle,
expandedSpans,
hoveredPercent,
forwardHover,
onTimelineHover,
onTimelineLeave,
gapBeforeMs = 0,
gapBeforePercent = 0,
showRelativeChip = true,
chipVisibility = { model: true, toolProvider: true, tokens: true, cost: true, relative: true },
}: TraceSpanItemProps): React.ReactNode {
const [localHoveredPercent, setLocalHoveredPercent] = useState<number | null>(null)
const spanId = span.id || `span-${span.name}-${span.startTime}`
const expanded = expandedSpans.has(spanId)
const hasChildren = span.children && span.children.length > 0
@@ -427,9 +428,18 @@ export function TraceSpanItem({
style={{ width: 'calc(45% - 73px)', pointerEvents: 'none' }}
>
<div
className='relative h-2 w-full overflow-visible rounded-full bg-accent/30'
className='relative h-2 w-full overflow-hidden bg-accent/30'
style={{ pointerEvents: 'auto' }}
onPointerMove={(e) => forwardHover(e.clientX, e.clientY)}
onPointerMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const clamped = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
setLocalHoveredPercent(clamped * 100)
onTimelineHover?.(e.clientX, e.clientY, rect)
}}
onPointerLeave={() => {
setLocalHoveredPercent(null)
onTimelineLeave?.()
}}
>
{gapBeforeMs > 5 && (
<div
@@ -456,7 +466,6 @@ export function TraceSpanItem({
? 'rgba(148, 163, 184, 0.28)'
: 'rgba(148, 163, 184, 0.32)'
const baseColor = type === 'workflow' ? neutralRail : softenColor(spanColor, isDark)
const isFlatBase = type !== 'workflow'
return (
<div
className='absolute h-full'
@@ -464,7 +473,6 @@ export function TraceSpanItem({
left: `${safeStartPercent}%`,
width: `${safeWidthPercent}%`,
backgroundColor: baseColor,
borderRadius: isFlatBase ? 0 : 9999,
zIndex: 5,
}}
/>
@@ -600,13 +608,15 @@ export function TraceSpanItem({
)
})
})()}
{hoveredPercent != null && (
{localHoveredPercent != null && (
<div
className='pointer-events-none absolute top-[-10px] bottom-[-10px] w-px bg-black/30 dark:bg-white/45'
style={{ left: `${Math.max(0, Math.min(100, hoveredPercent))}%`, zIndex: 12 }}
className='pointer-events-none absolute inset-y-0 w-px bg-black/30 dark:bg-white/45'
style={{
left: `${Math.max(0, Math.min(100, localHoveredPercent))}%`,
zIndex: 12,
}}
/>
)}
<div className='absolute inset-x-0 inset-y-[-12px] cursor-crosshair' />
</div>
</div>
@@ -658,7 +668,8 @@ export function TraceSpanItem({
onToggle={onToggle}
expandedSpans={expandedSpans}
hasSubItems={childHasSubItems}
forwardHover={forwardHover}
onTimelineHover={onTimelineHover}
onTimelineLeave={onTimelineLeave}
gapBeforeMs={childGapMs}
gapBeforePercent={childGapPercent}
showRelativeChip={chipVisibility.relative}
@@ -707,7 +718,8 @@ export function TraceSpanItem({
onToggle={onToggle}
expandedSpans={expandedSpans}
hasSubItems={hasToolCallData}
forwardHover={forwardHover}
onTimelineHover={onTimelineHover}
onTimelineLeave={onTimelineLeave}
showRelativeChip={chipVisibility.relative}
chipVisibility={chipVisibility}
/>

View File

@@ -19,10 +19,9 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
const [expandedSpans, setExpandedSpans] = useState<Set<string>>(new Set())
const [typeFilters, setTypeFilters] = useState<Record<string, boolean>>({})
const containerRef = useRef<HTMLDivElement | null>(null)
const timelineHitboxRef = useRef<HTMLDivElement | null>(null)
const [hoveredPercent, setHoveredPercent] = useState<number | null>(null)
const [hoveredWorkflowMs, setHoveredWorkflowMs] = useState<number | null>(null)
const [hoveredX, setHoveredX] = useState<number | null>(null)
const [hoveredY, setHoveredY] = useState<number | null>(null)
const [containerWidth, setContainerWidth] = useState<number>(0)
type ChipVisibility = {
@@ -151,39 +150,24 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
return traceSpans ? filterTree(traceSpans) : []
}, [traceSpans, effectiveTypeFilters])
const forwardHover = useCallback(
(clientX: number, clientY: number) => {
if (!timelineHitboxRef.current || !containerRef.current) return
const railRect = timelineHitboxRef.current.getBoundingClientRect()
const handleTimelineHover = useCallback(
(clientX: number, clientY: number, timelineRect: DOMRect) => {
if (!containerRef.current) return
const containerRect = containerRef.current.getBoundingClientRect()
const clamped = Math.max(0, Math.min(1, (clientX - timelineRect.left) / timelineRect.width))
const withinX = clientX >= railRect.left && clientX <= railRect.right
const withinY = clientY >= railRect.top && clientY <= railRect.bottom
if (!withinX || !withinY) {
setHoveredPercent(null)
setHoveredWorkflowMs(null)
setHoveredX(null)
return
}
const clamped = Math.max(0, Math.min(1, (clientX - railRect.left) / railRect.width))
setHoveredPercent(clamped * 100)
setHoveredWorkflowMs(workflowStartTime + clamped * actualTotalDuration)
setHoveredX(railRect.left + clamped * railRect.width - containerRect.left)
setHoveredX(timelineRect.left + clamped * timelineRect.width - containerRect.left)
setHoveredY(timelineRect.top - containerRect.top)
},
[actualTotalDuration, workflowStartTime]
)
useEffect(() => {
const handleMove = (event: MouseEvent) => {
forwardHover(event.clientX, event.clientY)
}
window.addEventListener('pointermove', handleMove)
return () => window.removeEventListener('pointermove', handleMove)
}, [forwardHover])
const handleTimelineLeave = useCallback(() => {
setHoveredWorkflowMs(null)
setHoveredX(null)
setHoveredY(null)
}, [])
useEffect(() => {
if (!containerRef.current) return
@@ -203,7 +187,7 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
}
return (
<div className='w-full'>
<div className='relative w-full'>
<div className='mb-2 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<div className='font-medium text-muted-foreground text-xs'>Workflow Execution</div>
@@ -234,11 +218,6 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
<div
ref={containerRef}
className='relative w-full overflow-hidden rounded-md border shadow-sm'
onMouseLeave={() => {
setHoveredPercent(null)
setHoveredWorkflowMs(null)
setHoveredX(null)
}}
>
{filtered.map((span, index) => {
const normalizedSpan = normalizeChildWorkflowSpan(span)
@@ -276,9 +255,8 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
onToggle={handleSpanToggle}
expandedSpans={expandedSpans}
hasSubItems={hasSubItems}
hoveredPercent={hoveredPercent}
hoveredWorkflowMs={hoveredWorkflowMs}
forwardHover={forwardHover}
onTimelineHover={handleTimelineHover}
onTimelineLeave={handleTimelineLeave}
gapBeforeMs={gapMs}
gapBeforePercent={gapPercent}
showRelativeChip={chipVisibility.relative}
@@ -286,29 +264,17 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
/>
)
})}
{/* Time label for hover (keep top label, row lines render per-row) */}
{hoveredPercent !== null && hoveredX !== null && (
<div
className='-translate-x-1/2 pointer-events-none absolute top-1 rounded bg-popover px-1.5 py-0.5 text-[10px] text-foreground shadow'
style={{ left: hoveredX, zIndex: 20 }}
>
{formatDurationDisplay(Math.max(0, (hoveredWorkflowMs || 0) - workflowStartTime))}
</div>
)}
{/* Hover capture area - aligned to timeline bars, not extending to edge */}
<div
ref={timelineHitboxRef}
className='pointer-events-auto absolute inset-y-0 right-[73px] w-[calc(45%-73px)]'
onPointerMove={(e) => forwardHover(e.clientX, e.clientY)}
onPointerLeave={() => {
setHoveredPercent(null)
setHoveredWorkflowMs(null)
setHoveredX(null)
}}
/>
</div>
{/* Time label for hover (positioned at top of timeline) */}
{hoveredWorkflowMs !== null && hoveredX !== null && hoveredY !== null && (
<div
className='-translate-x-1/2 pointer-events-none absolute rounded border bg-popover px-1.5 py-0.5 font-mono text-[10px] text-foreground shadow-lg'
style={{ left: hoveredX, top: hoveredY, zIndex: 20 }}
>
{formatDurationDisplay(Math.max(0, (hoveredWorkflowMs || 0) - workflowStartTime))}
</div>
)}
</div>
)
}