one request's spans as a waterfall: who called whom, how long each took, what failed
npx shadcn add @ag/traceAlready installed? Preview an update
npx shadcn add @ag/trace --dry-runYour installed source stays unchanged until you update it. How updates work
- Its frame is
Panel's (framedecides as before), so in glass it has the panel's spill and an edge over its content.
also called waterfall, spans, distributed tracing, flame graph
shelf data & status
uses clip, timeline-view, tree, readout, button, key-router, panel, scroll-thumb, scrims, material, telemetry
used by deployment
the demo above, verbatim
"use client"
import { Sample } from "@/app/samples"
import type { TraceSpan } from "@/registry/base-nova/lib/telemetry"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Trace } from "@/registry/base-nova/ui/trace"
// #region checkout
/** One checkout as four services traced it, in ms from the request's arrival. The
* first charge times out at the acquirer and the retry goes through; the receipt is
* a queued job the request never waits for, so it runs past its parent and says so. */
const checkout: TraceSpan[] = [
{
id: "a1",
name: "POST /checkout",
service: "frontend",
from: 0,
to: 412.6,
status: "ok",
attributes: {
"http.method": "POST",
"http.route": "/checkout",
"http.status_code": 200,
"user.tier": "plus",
},
},
{
id: "a2",
parentId: "a1",
name: "verify session",
service: "frontend",
from: 2.1,
to: 19.8,
status: "ok",
},
{
id: "a3",
parentId: "a2",
name: "redis GET session",
service: "frontend",
from: 3,
to: 5.4,
status: "ok",
attributes: { "db.system": "redis", "cache.hit": true },
},
{
id: "a4",
parentId: "a2",
name: "decode jwt",
service: "frontend",
from: 5.9,
to: 6.4,
status: "ok",
},
{
id: "b1",
parentId: "a1",
name: "PlaceOrder",
service: "checkout",
from: 22.4,
to: 404.9,
status: "ok",
attributes: { "rpc.system": "grpc", "rpc.method": "PlaceOrder" },
},
{
id: "b2",
parentId: "b1",
name: "load cart",
service: "checkout",
from: 24,
to: 61.3,
status: "ok",
},
{
id: "b3",
parentId: "b2",
name: "SELECT cart_items",
service: "checkout",
from: 25.2,
to: 46.8,
status: "ok",
attributes: { "db.system": "postgresql", "db.rows": 4 },
},
{
id: "b4",
parentId: "b2",
name: "SELECT products",
service: "checkout",
from: 47.5,
to: 59.9,
status: "ok",
attributes: { "db.system": "postgresql", "db.rows": 4 },
},
{
id: "b10",
parentId: "b1",
name: "validate address",
service: "checkout",
from: 61.5,
to: 62.8,
status: "ok",
},
{
id: "c1",
parentId: "b1",
name: "Reserve",
service: "inventory",
from: 63,
to: 148.2,
status: "ok",
attributes: { "rpc.system": "grpc", "inventory.skus": 4 },
},
{
id: "c2",
parentId: "c1",
name: "lock sku rows",
service: "inventory",
from: 65.1,
to: 81.7,
status: "ok",
},
{
id: "c3",
parentId: "c1",
name: "UPDATE stock",
service: "inventory",
from: 83,
to: 131.4,
status: "ok",
attributes: { "db.system": "postgresql", "db.rows": 4 },
},
{
id: "c4",
parentId: "c1",
name: "publish stock.reserved",
service: "inventory",
from: 132.9,
to: 145.5,
status: "ok",
attributes: { "messaging.system": "kafka" },
},
{
id: "b5",
parentId: "b1",
name: "price order",
service: "checkout",
from: 150.3,
to: 176,
status: "ok",
},
{
id: "b6",
parentId: "b5",
name: "tax quote",
service: "checkout",
from: 151.8,
to: 171.2,
status: "ok",
attributes: { "tax.region": "ES", "tax.rate": 0.21 },
},
{
id: "d1",
parentId: "b1",
name: "Charge",
service: "payments",
from: 179.6,
to: 371,
status: "ok",
attributes: { "payment.amount": 84.5, "payment.currency": "EUR" },
},
{
id: "d2",
parentId: "d1",
name: "fraud score",
service: "payments",
from: 182,
to: 213.7,
status: "ok",
attributes: { "fraud.score": 0.07 },
},
{
id: "d3",
parentId: "d1",
name: "POST /v1/charges",
service: "payments",
from: 215.2,
to: 316,
status: "error",
attributes: {
"http.status_code": 504,
"error.type": "timeout",
"retry.attempt": 1,
"net.peer.name": "api.acquirer.example",
},
},
{
id: "d4",
parentId: "d1",
name: "backoff",
service: "payments",
from: 316.1,
to: 328,
status: "ok",
},
{
id: "d5",
parentId: "d1",
name: "POST /v1/charges",
service: "payments",
from: 328.4,
to: 366.9,
status: "ok",
attributes: {
"http.status_code": 201,
"retry.attempt": 2,
"net.peer.name": "api.acquirer.example",
},
},
{
id: "b7",
parentId: "b1",
name: "INSERT orders",
service: "checkout",
from: 373,
to: 391.8,
status: "ok",
attributes: { "db.system": "postgresql", "db.rows": 1 },
},
{
id: "b8",
parentId: "b1",
name: "publish order.placed",
service: "checkout",
from: 392.5,
to: 401.3,
status: "ok",
attributes: { "messaging.system": "kafka" },
},
{
id: "b9",
parentId: "b8",
name: "send receipt",
service: "checkout",
from: 402,
to: 458.6,
status: "ok",
detached: "a queued job: the request does not wait for the email",
attributes: { "messaging.system": "kafka", "email.template": "receipt" },
},
{
id: "a6",
parentId: "a1",
name: "set cookie",
service: "frontend",
from: 405.1,
to: 405.9,
status: "ok",
},
{
id: "a5",
parentId: "a1",
name: "render confirmation",
service: "frontend",
from: 406.2,
to: 411.9,
status: "ok",
},
]
// #endregion
// #region search
/** A search fanned out over five services, five hundred spans, made the same every
* time (a seeded generator): each span's children share its time in order or fan
* out side by side, and a few of them fail. */
function searchTrace(count: number): TraceSpan[] {
let seed = 7
const random = () => {
seed = (seed * 1664525 + 1013904223) % 2 ** 32
return seed / 2 ** 32
}
const services = ["edge", "search", "ranker", "index", "cache"]
const verbs = [
"query",
"fetch",
"score",
"merge",
"lookup",
"decode",
"filter",
]
const root: TraceSpan = {
id: "s0",
name: "GET /search",
service: "edge",
from: 0,
to: 2400,
status: "ok",
}
const spans = [root]
const waiting = [root]
for (let parent = waiting.shift(); parent; parent = waiting.shift()) {
const room = parent.to - parent.from
const kids = 4 + Math.floor(random() * 6)
// A fan-out: the children run side by side, each most of the parent's time.
const fanOut = random() < 0.5
const slot = (room * 0.96) / kids
for (let k = 0; k < kids && spans.length < count; k++) {
const length = fanOut
? room * (0.4 + random() * 0.5)
: slot * (0.5 + random() * 0.4)
const from = fanOut
? parent.from + random() * (room - length)
: parent.from + room * 0.02 + k * slot + random() * slot * 0.1
const span: TraceSpan = {
id: `s${spans.length}`,
parentId: parent.id,
name: `${verbs[Math.floor(random() * verbs.length)]} ${spans.length}`,
service: services[Math.floor(random() * services.length)] as string,
from,
to: from + length,
status: random() < 0.01 ? "error" : "ok",
attributes: { shard: k },
}
spans.push(span)
waiting.push(span)
}
}
return spans
}
const search = searchTrace(500)
// #endregion
export default function TraceDemo() {
return (
<div className="space-y-8">
<Sample
name="capped"
glance="large"
title="a checkout, eight rows at a time"
with="checkout"
>
<Trace spans={checkout} label="POST /checkout" visibleRows={8} />
</Sample>
<Sample
name="checkout"
title="a checkout, its error chosen"
description="click a span or press Enter on it for its details; ← and → fold and open a parent, Esc lets the span go. ⌘ or ctrl with the wheel (or a pinch) zooms at the pointer, a sideways wheel pans, + − 0 from the keys; the details' magnifier zooms to the span. The charge's first attempt timed out; the receipt is a queued job that runs past the request, drawn hollow."
with="checkout"
>
<Trace spans={checkout} label="POST /checkout" defaultSelected="d3" />
</Sample>
<Sample
name="narrow"
title="narrow: the details below"
description="under 48 rem of its own width the details are a band of fixed height under the waterfall, there before anything is chosen, so a choice never moves what is below."
with="checkout"
>
<div className="max-w-xl">
<Trace spans={checkout} label="POST /checkout" visibleRows={10} />
</div>
</Sample>
<Sample
name="folded"
title="subtrees folded"
description="the inventory reservation and the charge start folded: their bars stay, their children wait behind the chevron."
with="checkout"
>
<Trace
spans={checkout}
label="POST /checkout"
defaultCollapsed={["c1", "d1"]}
/>
</Sample>
<Sample name="glass" title="on glass" with="checkout">
<MaterialProvider material="glass">
<div className="rounded-2xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-4 sm:p-8">
<Trace
spans={checkout}
label="POST /checkout"
defaultSelected="b6"
visibleRows={10}
/>
</div>
</MaterialProvider>
</Sample>
<Sample
name="large"
title="five hundred spans"
description="only the rows in view are mounted; the thumb says where you are, the edges fade where there is more."
with="search"
>
<Trace spans={search} label="GET /search" visibleRows={20} />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// ONE REQUEST'S SPANS AS A WATERFALL, as Jaeger, Honeycomb and Vercel draw a trace: a
// row per span, its name indented under its parent, and on one time axis shared by
// every row a bar from its start to its end. What it is for is reading where the time
// went: which call waited on which, what ran side by side, what failed.
//
// the data flat spans, each a span of time (`{from, to}`, ms, any epoch) with an
// id, its parent's id (none for the root), a name, the service that
// ran it, ok or error, and attributes. The tree is asserted whole: one
// root, every parent there, every span reached from the root, and a
// child inside its parent's span unless it says why not (`detached`:
// a queued job the request does not wait for, two hosts' clocks).
// the view the window of the trace the axis shows (lib/timeline-view, the one
// view model; instants are offsets from the trace's start, so the
// trace is its "medium"). The whole trace at first; ⌘ or ctrl with the
// wheel, or a pinch, zooms at the hand; a sideways wheel (or ⇧) pans,
// stopping at the ends; + − 0 from the keys, the buttons beside the
// legend, and a chosen span's own zoom. The narrowest view is four of
// the trace's shortest span, so its shortest leaf can be read.
// the axis bars and their places are shares of the view (percent), so nothing
// is measured to draw a row; only the ruler measures its width, to
// pick the tick step whose labels fit (1, 2, 5 × 10ⁿ). The column ends
// a duration label's width short of the row's end, so every label sits
// after its bar, the root's included. A bar that runs past the view is
// cut at its edge with a fade, never a hard edge.
// colour the service: a small fixed palette, the tokens' info tone turned
// round the hue circle at its own lightness and chroma, so it follows
// the scheme the tones do; services take it in order of first
// appearance down the rows. An error is the tokens' bad tone, never a
// service colour: a mark before the name, the row washed in it as a
// log washes an error line, the duration in it.
// lines a log's own lines (`lines`), listed in a span's details; the one
// under the hand there, or chosen (`line`), draws its instant as a
// playhead down the waterfall. Never a mark per line on every bar.
// the tree ui/tree-state's headless tree (`useTree`): the WAI-ARIA tree keys
// (↑ ↓ move, → opens or steps in, ← closes or steps out, Home End,
// type-ahead, * opens siblings), the roving focus, and the window of
// rows mounted, so five hundred spans cost what a screenful does. A
// click or Enter chooses a span; Esc lets it go.
// the details a region of its own that never moves the page: from 48 rem of the
// trace's own width a fixed column beside the waterfall, as tall as
// it; narrower, a band of fixed height under it. Always there, a quiet
// line until a span is chosen; then its name, service, start, duration,
// status and attributes as a readout, scrolling inside the region
// (scrims' `ag-edge-fade-y`) when they are many.
// the scroller capped at `visibleRows` (or, `"fill"`, as tall as the height the
// trace is given leaves it), the browser's bar hidden, overlay thumbs
// (scroll-thumb), and its cut edges faded where there is more
// (`ag-edge-fade-y`); the ruler stands above it, outside the fade.
//
// Both materials (ui/material): plain on the page's surfaces, glass over a picture;
// `scheme` the colour scheme it stands in. Inside a frame already drawn (a panel, a
// tabs box) it draws no box of its own.
import {
ChevronRight,
Maximize2,
Minus,
Plus,
TriangleAlert,
X,
ZoomIn,
} from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import type { Span } from "@/registry/base-nova/lib/clip"
import {
fieldText,
isRaw,
type LogEntry,
type LogLine,
type TraceSpan,
} from "@/registry/base-nova/lib/telemetry"
import {
clampView,
instantAt,
msPerPx,
panBy,
type View,
viewAround,
wholeView,
xOf,
zoomAt,
} from "@/registry/base-nova/lib/timeline-view"
import { Button } from "@/registry/base-nova/ui/button"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import {
type Material,
type Scheme,
TONE_WORDS,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants } from "@/registry/base-nova/ui/material-glass"
import { Panel, useInFrame } from "@/registry/base-nova/ui/panel"
import { Readout, ReadoutRow } from "@/registry/base-nova/ui/readout"
import {
NO_SCROLLBAR,
ScrollThumbs,
} from "@/registry/base-nova/ui/scroll-thumb"
import { useTree } from "@/registry/base-nova/ui/tree-state"
import "./edge-fade"
export interface TraceProps {
spans: readonly TraceSpan[]
/** What the trace is of, its accessible name ("POST /checkout"). */
label: string
/** The span whose details show (controlled), null for none. */
selected?: string | null
defaultSelected?: string | null
onSelectedChange?: (id: string | null) => void
/** The lines written in its spans (a log's own entries, lib/telemetry): each a dot
* on its span's bar where it was written, and listed in its span's details. Lines
* of spans not in the trace, and lines that are no event, are left out. */
lines?: readonly LogEntry[]
/** The line shown as chosen (controlled), null for none: its tick and its row in
* the details stand out. With `onLineChange`, a log beside the trace names it. */
line?: string | null
/** A line chosen in the details: where a log beside the trace goes to it. */
onLineChange?: (id: string) => void
/** Parents whose children start folded away. */
defaultCollapsed?: readonly string[]
/** How many rows show before the waterfall scrolls; `"fill"`, as many as the
* height the trace is given holds (a tabs box's `height`, a dashboard's cell).
* Default 16. */
visibleRows?: number | "fill"
/** Its own box (a hairline, its corners and ground; in glass, a surface). Default
* drawn, except inside a frame already drawn (a panel, a tabs box: panel's
* `InFrame`). */
frame?: boolean
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
className?: string
}
/** A span as a node of ui/tree-state's tree. */
type SpanNode = { id: string; span: TraceSpan; children?: SpanNode[] }
/** The trace as drawn: its one root, the whole of it, each service's colour, the
* parents (whose subtrees fold) and its shortest span (how far it zooms). */
interface Shape {
roots: readonly SpanNode[]
byId: ReadonlyMap<string, TraceSpan>
whole: Span
inks: ReadonlyMap<string, string>
branches: readonly string[]
errors: number
shortest: number
}
// ── sizes, px ────────────────────────────────────────────────────────────────────
/** One row: the tree's compact density. */
const ROW_H = 24
/** The indent's grid, the tree's: a level is the chevron's gutter and its gap. */
const GRID = { pad: 6, gutter: 16, gap: 2 }
const STEP = GRID.gutter + GRID.gap
/** Mono at 10 px: one character's advance, the ruler's and the durations' type. */
const CHAR_PX = 6.2
/** Between a bar's end and its duration. */
const LABEL_GAP = 6
/** The longest duration label, in characters: "12.4 ms", "9.99 ms". */
const LABEL_CHARS = 7
/** The room after the axis: the longest duration beside a bar that ends there. */
const GUTTER_PX = LABEL_GAP + LABEL_CHARS * CHAR_PX
/** Room a ruler label keeps clear after itself, past its own characters. */
const TICK_ROOM_PX = 14
/** How far into the axis a bar cut by the view fades out. */
const CUT_PX = 16
/** The narrowest view, in the trace's shortest spans: that span a quarter of it. */
const SHORTEST_IN_VIEW = 4
/** What a wheel's line is in px, when a mouse reports lines. */
const LINE_PX = 16
/** The zoom a pinch's delta buys: exp(-delta × this). */
const ZOOM_PER_PX = 0.01
/** The service palette: the tokens' info tone at these hues, its own lightness and
* chroma kept, so each reads as one family with the tones in either scheme. Eight,
* 30° apart from 110° to 320°: none near the bad tone's red (27°), which is the
* error's alone, nor the warn tone's amber. Taken in this order, so the first
* services are the furthest apart; a ninth repeats the first. */
const HUES = [230, 110, 320, 170, 260, 140, 290, 200] as const
const inkOf = (n: number) =>
`oklch(from var(--tone-info) l c ${HUES[n % HUES.length]})`
/** The tree's own parts, whose keys it answers itself. */
const TREE_ROLES = ["tree", "treeitem", "group"]
/** The name column and the axis, the same for the ruler's head and every row. */
const COLUMNS = "grid grid-cols-[minmax(12rem,34%)_minmax(0,1fr)]"
const ROW =
"group/row absolute inset-x-0 top-0 h-(--ag-trace-row) translate-y-(--ag-trace-y) cursor-default select-none items-center text-foreground/80 focus-visible:-outline-offset-2 aria-selected:text-foreground"
/** Plain: rows lit in the foreground's tiers. Glass: the chosen row is a pane of the
* material's glass, the others lit under the hand only (the tree's faces). */
const ROW_FACE: Record<Material, string> = {
// Square, edge to edge, as the log's lines are: washed rows side by side are one
// band, never a column of rounded pills notched where their corners meet.
plain: "hover:bg-foreground/5 aria-selected:bg-foreground/10",
glass:
"rounded-(--glass-radius) [--glass-radius:8px] hover:not-aria-selected:bg-foreground/8",
}
/** A failed span's row washed in the bad tone, as the log washes an error line, so
* both views say it the same way and a failure is found while scrolling; a step
* more under the hand and when chosen. After ROW_FACE, so it wins over its washes. */
const ERROR_WASH =
"bg-tone-bad/9 hover:bg-tone-bad/15 aria-selected:bg-tone-bad/20"
const META = "font-mono text-[11px] text-foreground/50 tabular-nums"
const SWATCH = "size-2 shrink-0 rounded-full bg-(--ag-trace-ink)"
/** The instant a line was written, drawn only when asked for (a line hovered in the
* details, a line chosen in a log beside the trace), as a playhead: a hairline down
* the waterfall and a caret on the ruler, in the line's tone. Marks on every bar for
* every line were noise (most lines fall at a span's start or end) and fought the
* bars and their durations in a 24 px row. */
const CURSOR: Record<LogLine["level"], string> = {
trace: "bg-foreground/70",
debug: "bg-foreground/70",
info: "bg-foreground/70",
warn: "bg-tone-warn",
error: "bg-tone-bad",
}
/** A length of time in three figures: "840 µs", "12.4 ms", "1.24 s". */
export function durationWords(ms: number): string {
if (ms < 1) return `${Math.round(ms * 1000)} µs`
const [n, unit] = ms < 1000 ? [ms, "ms"] : [ms / 1000, "s"]
return `${n.toFixed(n < 10 ? 2 : n < 100 ? 1 : 0)} ${unit}`
}
/** The spans as a tree, asserted whole (see the header). */
function shapeOf(spans: readonly TraceSpan[]): Shape {
if (spans.length === 0) throw new Error("trace: no spans")
const byId = new Map<string, TraceSpan>()
for (const s of spans) {
if (byId.has(s.id)) throw new Error(`trace: two spans are "${s.id}"`)
if (!Number.isFinite(s.from) || !Number.isFinite(s.to) || s.to < s.from)
throw new Error(
`trace: span "${s.id}" runs from ${s.from} to ${s.to}, no span of time`,
)
byId.set(s.id, s)
}
const roots = spans.filter((s) => s.parentId === undefined)
if (roots.length !== 1)
throw new Error(
`trace: ${roots.length} spans have no parent (${roots.map((r) => `"${r.id}"`).join(", ")}); a trace has one root`,
)
const kids = new Map<string, TraceSpan[]>()
for (const s of spans) {
if (s.parentId === undefined) continue
const parent = byId.get(s.parentId)
if (!parent)
throw new Error(
`trace: span "${s.id}" names parent "${s.parentId}", which is not in the trace`,
)
if (!s.detached && (s.from < parent.from || s.to > parent.to))
throw new Error(
`trace: span "${s.id}" (${s.from} to ${s.to}) runs outside its parent "${parent.id}" (${parent.from} to ${parent.to}); say why with \`detached\` if it is meant to`,
)
const list = kids.get(parent.id) ?? []
list.push(s)
kids.set(parent.id, list)
}
const branches: string[] = []
let reached = 0
const build = (s: TraceSpan): SpanNode => {
reached++
const children = (kids.get(s.id) ?? [])
.toSorted((a, b) => a.from - b.from || b.to - a.to)
.map(build)
if (children.length > 0) branches.push(s.id)
return children.length > 0
? { id: s.id, span: s, children }
: { id: s.id, span: s }
}
const root = build(roots[0] as TraceSpan)
if (reached !== spans.length)
throw new Error(
`trace: ${spans.length - reached} spans are never reached from the root "${root.id}": their parents name each other round in a loop`,
)
// Services take their colours in the order the rows show them.
const inks = new Map<string, string>()
const walk = (n: SpanNode) => {
if (!inks.has(n.span.service)) inks.set(n.span.service, inkOf(inks.size))
for (const c of n.children ?? []) walk(c)
}
walk(root)
const whole = {
from: Math.min(...spans.map((s) => s.from)),
to: Math.max(...spans.map((s) => s.to)),
}
if (whole.to <= whole.from)
throw new Error("trace: every span is of no length; there is no axis")
return {
roots: [root],
byId,
whole,
inks,
branches,
errors: spans.filter((s) => s.status === "error").length,
// The root has a length (asserted above), so there is always one.
shortest: Math.min(
...spans.map((s) => s.to - s.from).filter((length) => length > 0),
),
}
}
/** Where an offset falls along the view, as a share of the axis held to its ends. */
const shareIn = (view: View, ms: number) =>
Math.min(100, Math.max(0, xOf(view, 100, ms)))
/** The ruler's label for an instant: in the unit the view's length is said in
* (`durationWords`'), with the decimals its step needs ("0.2 s", "215.4 ms"). */
function tickWords(at: number, step: number, length: number) {
if (at === 0) return "0"
const [unit, per] =
length < 1 ? ["µs", 0.001] : length < 1000 ? ["ms", 1] : ["s", 1000]
const decimals = Math.max(0, -Math.floor(Math.log10(step / per) + 1e-9))
return `${(at / per).toFixed(decimals)} ${unit}`
}
/** The ruler's ticks across `view` drawn `px` wide: the smallest step of 1, 2 or
* 5 × 10ⁿ whose widest label fits before the next tick. */
function ticksOf(view: View, px: number) {
if (px <= 0) return []
const length = view.to - view.from
// No step narrower than one character and its room can fit: the search starts at
// the power of ten holding that one, so it never lays out more ticks than the width
// has room for.
const least = (length * (CHAR_PX + TICK_ROOM_PX)) / px
const first = Math.floor(Math.log10(least))
for (let power = first; power <= first + 3; power++)
for (const m of [1, 2, 5]) {
const step = m * 10 ** power
if (step < least) continue
const ats: number[] = []
for (
let k = Math.ceil(view.from / step - 1e-9);
k * step <= view.to + step * 1e-9;
k++
)
ats.push(k * step)
const words = ats.map((at) => tickWords(at, step, length))
const widest = Math.max(...words.map((w) => w.length)) * CHAR_PX
if ((step / length) * px >= widest + TICK_ROOM_PX)
return ats.map((at, i) => ({ at, label: words[i] as string }))
}
throw new Error(`trace: no tick step fits ${length} ms in ${px} px`)
}
export function Trace({
spans,
label,
selected,
defaultSelected = null,
onSelectedChange,
lines,
line,
onLineChange,
defaultCollapsed = [],
visibleRows = 16,
frame,
material,
scheme,
className,
}: TraceProps) {
const m = useMaterial(material)
const inFrame = useInFrame()
const framed = frame ?? !inFrame
const cap = visibleRows === "fill" ? null : visibleRows * ROW_H
const root = React.useRef<HTMLElement>(null)
const [sch, here] = useSurfaceScheme<HTMLElement>(scheme, root)
const shape = React.useMemo(() => shapeOf(spans), [spans])
const { whole, inks, byId } = shape
// Each span's lines, in the order written; a span's own start and end are its bar's
// ends, so they are no lines of it here.
const linesOf = React.useMemo(() => {
const out = new Map<string, LogLine[]>()
for (const l of lines ?? []) {
if (isRaw(l) || !l.span || l.phase || !byId.has(l.span.id)) continue
const list = out.get(l.span.id) ?? []
list.push(l)
out.set(l.span.id, list)
}
return out
}, [lines, byId])
// The line whose instant is drawn: one hovered in the details, else the chosen one.
const [hovered, setHovered] = React.useState<string | null>(null)
const cursor = React.useMemo(() => {
const id = hovered ?? line
if (!id) return undefined
for (const list of linesOf.values()) {
const found = list.find((l) => l.id === id)
if (found) return found
}
return undefined
}, [hovered, line, linesOf])
const total = whole.to - whole.from
const minSpan = Math.min(total, shape.shortest * SHORTEST_IN_VIEW)
// ── the chosen span, controlled or not.
if (selected != null && !byId.has(selected))
throw new Error(`trace: selected "${selected}" is no span of the trace`)
const [own, setOwn] = React.useState(defaultSelected)
const chosenId = selected !== undefined ? selected : own
const chosen = chosenId === null ? undefined : byId.get(chosenId)
const choose = (id: string | null) => {
if (selected === undefined) setOwn(id)
onSelectedChange?.(id)
}
// ── the view: offsets from the trace's start, the whole trace until moved.
const [viewState, setViewState] = React.useState<View | null>(null)
const view = React.useMemo(
() => clampView(viewState ?? wholeView(total), total, minSpan),
[viewState, total, minSpan],
)
const live = React.useRef({ total, minSpan })
live.current = { total, minSpan }
const moveView = React.useCallback((next: (v: View) => View) => {
setViewState((was) => {
const { total: t, minSpan: min } = live.current
return clampView(next(clampView(was ?? wholeView(t), t, min)), t, min)
})
}, [])
const zoomed = view.from > 0 || view.to < total
const zoomBy = (factor: number) =>
moveView((v) => zoomAt(v, (v.from + v.to) / 2, factor, total, minSpan))
// A span shorter than the narrowest view is widened round its middle first, so it
// lands in the middle of the view (viewAround widens from its start).
const zoomTo = (s: Span) => {
const middle = (s.from + s.to) / 2 - whole.from
const half = Math.max(s.to - s.from, minSpan) / 2
moveView(() =>
viewAround({ from: middle - half, to: middle + half }, total, minSpan),
)
}
const showWhole = () => moveView(() => wholeView(total))
// ── the tree: keys, focus, the rows mounted. What is kept is what was folded, not
// what is open: a trace that grows (a build still running) gets new parents, and
// each opens as the first ones did.
const [folded, setFolded] = React.useState<ReadonlySet<string>>(
() => new Set(defaultCollapsed),
)
const expanded = React.useMemo(
() => shape.branches.filter((id) => !folded.has(id)),
[shape.branches, folded],
)
const t = useTree<SpanNode>({
items: shape.roots,
label: nameOf,
selection: "none",
expanded,
onExpandedChange: (open) => {
const now = new Set(open)
setFolded(new Set(shape.branches.filter((id) => !now.has(id))))
},
onActivate: (n) => choose(n.id),
rowHeight: ROW_H,
})
const { rows, scroller } = t
// A span chosen from the start is in the middle of the view, and the row the keys
// start from: details of a row nobody can see answer nothing.
// biome-ignore lint/correctness/useExhaustiveDependencies: on mount only
React.useLayoutEffect(() => {
const at = chosenId === null ? undefined : t.rowAt.get(chosenId)
const el = scroller.current
if (at === undefined || chosenId === null || !el) return
el.scrollTop = Math.max(0, (at + 0.5) * ROW_H - el.clientHeight / 2)
t.setActive(chosenId)
}, [])
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!e.currentTarget.contains(e.target as Node)) return
if (ownedHere(e.nativeEvent, e.currentTarget, TREE_ROLES)) return
t.keys(e, (e) => {
const mod = e.metaKey || e.ctrlKey || e.altKey
if (e.key === "Escape" && chosen !== undefined) choose(null)
else if (!mod && (e.key === "+" || e.key === "=")) zoomBy(2)
else if (!mod && e.key === "-") zoomBy(0.5)
else if (!mod && e.key === "0") showWhole()
else return false
e.preventDefault()
return true
})
}
// ── the ruler: its width picks the step and turns a wheel's px into ms.
const ruler = React.useRef<HTMLDivElement>(null)
const [rulerPx, setRulerPx] = React.useState(0)
React.useLayoutEffect(() => {
const el = ruler.current as HTMLDivElement
const measure = () => setRulerPx(el.clientWidth)
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
const ticks = React.useMemo(() => ticksOf(view, rulerPx), [view, rulerPx])
// ── the wheel over the waterfall: ⌘ or ctrl (a pinch) zooms at the hand, sideways
// (or ⇧) pans; up and down stay the rows' scroll.
const waterfall = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
const el = waterfall.current as HTMLDivElement
const onWheel = (e: WheelEvent) => {
const axis = ruler.current?.getBoundingClientRect()
if (!axis || axis.width <= 0) return
const unit = e.deltaMode === 1 ? LINE_PX : 1
const dx = e.deltaX * unit
const dy = e.deltaY * unit
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const x = Math.min(axis.width, Math.max(0, e.clientX - axis.left))
moveView((v) =>
zoomAt(
v,
instantAt(v, axis.width, x),
Math.exp(-dy * ZOOM_PER_PX),
live.current.total,
live.current.minSpan,
),
)
return
}
const sideways = e.shiftKey
? dy || dx
: Math.abs(dx) > Math.abs(dy)
? dx
: 0
if (!sideways) return
e.preventDefault()
moveView((v) =>
panBy(v, sideways * msPerPx(v, axis.width), live.current.total),
)
}
el.addEventListener("wheel", onWheel, { passive: false })
return () => el.removeEventListener("wheel", onWheel)
}, [moveView])
const glass = m === "glass"
/** A span's place in the view: its start and its length as shares of the axis,
* held to the axis's ends, and where its duration goes. */
const placeOf = (s: Span) => {
const from = shareIn(view, s.from - whole.from)
const to = shareIn(view, s.to - whole.from)
return {
"--ag-trace-from": `${from}%`,
"--ag-trace-width": `${to - from}%`,
"--ag-trace-end": `${to}%`,
}
}
const band = chosen && placeOf(chosen)
// The playhead only where the view shows its instant.
const cursorIn =
cursor !== undefined &&
cursor.at - whole.from >= view.from &&
cursor.at - whole.from <= view.to
// The axis fades out at an edge where the view cuts the trace.
const cut = zoomed
? `linear-gradient(to right, transparent, #000 ${view.from > 0 ? CUT_PX : 0}px, #000 calc(100% - ${view.to < total ? CUT_PX : 0}px), transparent)`
: "none"
const tickAt = (at: number) =>
({ "--ag-trace-at": `${xOf(view, 100, at)}%` }) as React.CSSProperties
// The trace is a Panel (the one container, plain or glass), its surface drawn
// only where no frame is drawn round it already.
const face = {
ref: here,
"data-slot": "trace",
className: cn(
"@container/trace relative isolate flex flex-col gap-0 p-3 text-[13px] text-foreground",
className,
),
style: {
"--ag-trace-row": `${ROW_H}px`,
"--ag-trace-gutter": `${GUTTER_PX}px`,
"--ag-trace-cut": cut,
} as React.CSSProperties,
}
const inner = (
<>
{/* Given a height (`fill`), the waterfall takes what the details leave and
its rows the rest of that. */}
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 @3xl/trace:grid-cols-[minmax(0,1fr)_20rem] @3xl/trace:grid-rows-[minmax(0,1fr)]">
<div
ref={waterfall}
data-slot="trace-waterfall"
className="flex min-h-0 min-w-0 flex-col gap-2"
>
<div
data-slot="trace-legend"
className={cn(
"flex flex-wrap items-center gap-x-3 gap-y-1 pl-1.5",
META,
)}
>
{[...inks].map(([service, ink]) => (
<span
key={service}
data-slot="trace-service-key"
className="flex items-center gap-1.5"
style={{ "--ag-trace-ink": ink } as React.CSSProperties}
>
<span aria-hidden className={SWATCH} />
{service}
</span>
))}
<span className="ml-auto flex items-center gap-3">
{shape.errors > 0 && (
<span className={TONE_WORDS.bad}>
{shape.errors} {shape.errors === 1 ? "error" : "errors"}
</span>
)}
<span>
{spans.length} spans, {durationWords(total)}
</span>
<span data-slot="trace-zoom" className="flex items-center">
<Button
variant="ghost"
size="icon-sm"
material={m}
aria-label="zoom out (-)"
disabled={!zoomed}
onClick={() => zoomBy(0.5)}
>
<Minus />
</Button>
<Button
variant="ghost"
size="icon-sm"
material={m}
aria-label="zoom in (+)"
disabled={view.to - view.from <= minSpan}
onClick={() => zoomBy(2)}
>
<Plus />
</Button>
<Button
variant="ghost"
size="icon-sm"
material={m}
aria-label="the whole trace (0)"
disabled={!zoomed}
onClick={showWhole}
>
<Maximize2 />
</Button>
</span>
</span>
</div>
<div
data-slot="trace-head"
className={cn(COLUMNS, "h-7 border-foreground/10 border-b")}
>
<span className={cn("self-end pb-1 pl-1.5", META, "text-[10px]")}>
span
</span>
<div
ref={ruler}
aria-hidden
data-slot="trace-ruler"
data-zoomed={zoomed || undefined}
className="relative mr-(--ag-trace-gutter)"
>
{band && (
<span
data-slot="trace-ruler-chosen"
// The chosen span's reach on the ruler: a line along its foot, not
// a block behind its labels.
className="absolute bottom-0 left-(--ag-trace-from) h-0.5 w-(--ag-trace-width) min-w-px rounded-full bg-foreground/60"
style={band as React.CSSProperties}
/>
)}
{cursorIn && cursor && (
<span
data-slot="trace-cursor-caret"
className={cn(
"absolute bottom-0 left-(--ag-trace-at) h-2 w-0.5 -translate-x-1/2 rounded-t-full",
CURSOR[cursor.level],
)}
style={tickAt(cursor.at - whole.from)}
/>
)}
{ticks.map((tick) => (
<span
key={tick.at}
className="absolute bottom-0 left-(--ag-trace-at)"
style={tickAt(tick.at)}
>
<span className="absolute bottom-0 h-1.5 w-px bg-foreground/35" />
<span className="absolute bottom-2 left-1 whitespace-nowrap font-mono text-[10px] text-foreground/55 tabular-nums">
{tick.label}
</span>
</span>
))}
</div>
</div>
<div
ref={scroller}
role="tree"
aria-label={label}
data-slot="trace-rows"
onKeyDown={onKeyDown}
onScroll={t.onScroll}
className={cn(
"ag-edge-fade-y relative overflow-x-hidden [--edge-fade:1.5rem]",
cap === null ? "min-h-0 flex-1" : "max-h-(--ag-trace-max)",
NO_SCROLLBAR,
)}
style={
cap === null
? undefined
: ({ "--ag-trace-max": `${cap}px` } as React.CSSProperties)
}
>
<ScrollThumbs scroller={scroller} material={m} />
<div
className="relative h-(--ag-trace-height)"
style={
{
"--ag-trace-height": `${rows.length * ROW_H}px`,
} as React.CSSProperties
}
>
{/* The ruler's ticks carried down the rows, and the chosen span's band. */}
<div
aria-hidden
data-slot="trace-grid"
className={cn(COLUMNS, "pointer-events-none absolute inset-0")}
>
<span />
<span className="relative mr-(--ag-trace-gutter)">
{band && (
<span
className="absolute inset-y-0 left-(--ag-trace-from) w-(--ag-trace-width) min-w-px bg-foreground/[0.05]"
style={band as React.CSSProperties}
/>
)}
{ticks.map((tick) => (
<span
key={tick.at}
data-slot="trace-grid-line"
className="absolute inset-y-0 left-(--ag-trace-at) w-px bg-foreground/[0.06]"
style={tickAt(tick.at)}
/>
))}
{cursorIn && cursor && (
<span
data-slot="trace-cursor"
data-level={cursor.level}
className={cn(
"absolute inset-y-0 left-(--ag-trace-at) w-px -translate-x-1/2",
CURSOR[cursor.level],
)}
style={tickAt(cursor.at - whole.from)}
/>
)}
</span>
</div>
{t.drawn.map((i) => {
const row = rows[i] as (typeof rows)[number]
const s = row.node.span
const error = s.status === "error"
const running = s.status === "running"
const isChosen = chosenId === row.id
const took = running ? "running" : durationWords(s.to - s.from)
// Wholly before or after the view: no bar and no duration to show.
const inView =
s.to - whole.from >= view.from &&
s.from - whole.from <= view.to
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: the tree's onKeyDown is every row's keyboard
<div
key={row.id}
role="treeitem"
data-slot="trace-row"
data-id={row.id}
data-status={s.status}
data-detached={s.detached ? "" : undefined}
aria-level={row.depth + 1}
aria-setsize={row.size}
aria-posinset={row.index}
aria-expanded={row.branch ? row.expanded : undefined}
aria-selected={isChosen}
aria-label={`${s.name}, ${s.service}, ${took}, from +${durationWords(s.from - whole.from)}${error ? ", error" : ""}${linesOf.has(s.id) ? `, ${(linesOf.get(s.id) as LogLine[]).length} lines` : ""}`}
tabIndex={row.id === t.activeId ? 0 : -1}
className={cn(
glass && isChosen && glassVariants({ shape: "surface" }),
COLUMNS,
ROW,
ROW_FACE[m],
// A chosen row in glass is a pane of glass, which a wash
// would cover.
error && !(glass && isChosen) && ERROR_WASH,
)}
style={
{
"--ag-trace-y": `${i * ROW_H}px`,
"--ag-trace-indent": `${GRID.pad + row.depth * STEP}px`,
"--ag-trace-ink": inks.get(s.service) as string,
...placeOf(s),
} as React.CSSProperties
}
onFocus={() => {
if (t.activeId !== row.id) t.setActive(row.id)
}}
onClick={() => {
t.setActive(row.id)
choose(row.id)
}}
onDoubleClick={() => {
if (row.branch) t.toggle(row.id)
}}
>
<span
data-slot="trace-name"
className="flex min-w-0 items-center pr-3 pl-(--ag-trace-indent)"
>
<span
aria-hidden
data-slot="trace-disclosure"
className="grid size-4 shrink-0 place-items-center text-foreground/40 hover:text-foreground"
onClick={(e) => {
if (!row.branch) return
e.stopPropagation()
t.setActive(row.id)
t.toggle(row.id)
}}
onDoubleClick={(e) => e.stopPropagation()}
>
{row.branch && (
<ChevronRight
className={cn(
"size-3 transition-transform duration-150 ease-out motion-reduce:transition-none",
row.expanded && "rotate-90",
)}
/>
)}
</span>
<span aria-hidden className={cn(SWATCH, "ml-0.5")} />
{error && (
<TriangleAlert
aria-hidden
data-slot="trace-error"
className="ml-1.5 size-3.5 shrink-0 text-tone-bad"
/>
)}
{/* The service is shown whole or not at all: with no room for
it beside the name it wraps onto a second line, which the
one line's height hides (cut to a letter, "p", it read as
noise). The name truncates on the first. Under 36 rem of
the trace it is left to the legend, which names its
colour; with one service the legend says it once and
no row repeats it. */}
<span className="ml-1.5 flex h-5 min-w-0 flex-1 flex-wrap items-baseline gap-x-2 overflow-hidden leading-5">
<span className="min-w-0 max-w-full truncate">
{s.name}
</span>
{inks.size > 1 && (
<span
data-slot="trace-service"
className={cn(
"shrink-0 @max-xl/trace:hidden",
META,
"text-foreground/40",
)}
>
{s.service}
</span>
)}
</span>
</span>
<span className="relative h-full">
{/* The axis: cut by the view, faded where it cuts. */}
{/* Clipped only while zoomed, where a bar runs past the view:
whole, a bar at the trace's start or end keeps its error
ring, which stands outside the bar. */}
<span
data-slot="trace-axis"
className={cn(
"absolute inset-y-0 right-(--ag-trace-gutter) left-0",
zoomed && "overflow-hidden mask-(--ag-trace-cut)",
)}
>
{inView && (
<span
data-slot="trace-bar"
className={cn(
"absolute top-1/2 left-(--ag-trace-from) h-2.5 w-(--ag-trace-width) min-w-px -translate-y-1/2 rounded-[3px]",
s.detached
? "border border-(--ag-trace-ink) bg-(--ag-trace-ink)/25"
: "bg-(--ag-trace-ink)",
// Its end not written: it fades out where the stream
// ends, still going for all the stream knows.
running &&
"rounded-r-none mask-[linear-gradient(to_right,#000_calc(100%-24px),transparent)]",
)}
/>
)}
</span>
{inView && (
<span
data-slot="trace-duration"
className="pointer-events-none absolute inset-y-0 right-(--ag-trace-gutter) left-0"
>
<span
className={cn(
"absolute top-1/2 left-(--ag-trace-end) ml-1.5 -translate-y-1/2 whitespace-nowrap font-mono text-[10px] tabular-nums",
error ? TONE_WORDS.bad : "text-foreground/55",
)}
>
{took}
</span>
</span>
)}
</span>
</div>
)
})}
</div>
</div>
</div>
<Details
span={chosen}
whole={whole}
ink={chosen ? (inks.get(chosen.service) as string) : undefined}
material={m}
spanLines={chosen ? (linesOf.get(chosen.id) ?? []) : []}
line={line}
onLine={onLineChange}
onHover={setHovered}
onZoom={() => chosen && zoomTo(chosen)}
onClose={() => {
if (!chosen) return
choose(null)
t.handle.focus(chosen.id)
}}
/>
</div>
{/* The view in words for assistive tech, said as it moves. */}
<p aria-live="polite" className="sr-only">
{zoomed
? `showing +${durationWords(view.from)} to +${durationWords(view.to)} of ${durationWords(total)}`
: ""}
</p>
</>
)
return (
<Panel {...face} frame={framed} material={m} scheme={sch}>
{inner}
</Panel>
)
}
const nameOf = (n: SpanNode) => n.span.name
/** A span's line in its details: when, in its tone, what. */
const LINE_ROW =
"grid grid-cols-[9ch_minmax(0,1fr)] items-baseline gap-x-2 rounded-md px-1.5 py-0.5 font-mono text-[11px] leading-4 [&>:first-child]:whitespace-nowrap"
function LineWords({ line, from }: { line: LogLine; from: number }) {
return (
<>
<span className="text-foreground/45 tabular-nums">
+{durationWords(line.at - from)}
</span>
<span
className={cn(
"min-w-0 [overflow-wrap:anywhere]",
line.level === "warn"
? TONE_WORDS.warn
: line.level === "error"
? TONE_WORDS.bad
: "text-foreground/80",
)}
>
{line.message}
</span>
</>
)
}
/** The details region: the same size whatever it holds (a column beside the
* waterfall as tall as it, or a band of fixed height under it), so a choice never
* moves the page. A quiet line until a span is chosen; then what it is, where it
* starts and how long it took, its status, then its attributes, scrolling inside
* the region when they are many. */
function Details({
span,
whole,
ink,
material,
spanLines,
line,
onLine,
onHover,
onZoom,
onClose,
}: {
span: TraceSpan | undefined
whole: Span
ink: string | undefined
material: Material
/** The chosen span's own lines, in the order written. */
spanLines: readonly LogLine[]
line: string | null | undefined
onLine: ((id: string) => void) | undefined
onHover: (id: string | null) => void
onZoom: () => void
onClose: () => void
}) {
const error = span?.status === "error"
const attributes = Object.entries(span?.attributes ?? {})
return (
<div
data-slot="trace-details"
data-empty={span ? undefined : ""}
className="relative h-48 min-w-0 border-foreground/10 border-t @3xl/trace:h-auto @3xl/trace:border-t-0 @3xl/trace:border-l"
style={
ink ? ({ "--ag-trace-ink": ink } as React.CSSProperties) : undefined
}
>
<div className="ag-edge-fade-y absolute inset-0 px-1.5 pt-3 pb-1 [--edge-fade:1rem] @3xl/trace:pt-0 @3xl/trace:pl-4">
{!span ? (
<p className={cn("m-0 pt-1", META, "text-foreground/40")}>
choose a span to read its details
</p>
) : (
<div className="grid gap-3">
<div className="flex min-w-0 items-center gap-2">
<span aria-hidden className={SWATCH} />
{error && (
<TriangleAlert
aria-hidden
className="size-3.5 shrink-0 text-tone-bad"
/>
)}
<h3
data-slot="trace-details-name"
className="m-0 min-w-0 truncate font-medium text-sm"
>
{span.name}
</h3>
<span className={cn("shrink-0", META)}>{span.service}</span>
<span className="ml-auto flex shrink-0 items-center">
<Button
variant="ghost"
size="icon-sm"
material={material}
aria-label="zoom to this span"
onClick={onZoom}
>
<ZoomIn />
</Button>
<Button
variant="ghost"
size="icon-sm"
material={material}
aria-label="close the span's details (Esc)"
onClick={onClose}
>
<X />
</Button>
</span>
</div>
<SpanLines
span={span}
lines={spanLines}
line={line}
onLine={onLine}
onHover={onHover}
/>
<div className="grid gap-x-8 gap-y-3 @xl/trace:grid-cols-2 @3xl/trace:grid-cols-1">
<Readout material={material} data-slot="trace-details-span">
<ReadoutRow label="starts">
+{durationWords(span.from - whole.from)}
</ReadoutRow>
<ReadoutRow label="duration">
{span.status === "running"
? `${durationWords(span.to - span.from)} so far`
: durationWords(span.to - span.from)}
</ReadoutRow>
<ReadoutRow label="status" tone={error ? "bad" : undefined}>
{span.status}
</ReadoutRow>
{span.detached && (
<ReadoutRow label="detached">{span.detached}</ReadoutRow>
)}
<ReadoutRow label="span id">{span.id}</ReadoutRow>
</Readout>
{attributes.length > 0 && (
<Readout
material={material}
aria-label="attributes"
data-slot="trace-details-attributes"
>
{attributes.map(([key, value]) => (
<ReadoutRow
key={key}
label={<span className="font-mono text-xs">{key}</span>}
>
{fieldText(value)}
</ReadoutRow>
))}
</Readout>
)}
</div>
</div>
)}
</div>
</div>
)
}
/** A span's lines in its details, first under its name: the way from the trace to
* the log beside it. A line is a button when there is a log to go to, words when
* not. */
function SpanLines({
span,
lines,
line,
onLine,
onHover,
}: {
span: TraceSpan
lines: readonly LogLine[]
line: string | null | undefined
onLine: ((id: string) => void) | undefined
/** A line under the hand or the focus, null when it leaves: the waterfall draws
* its instant. */
onHover: (id: string | null) => void
}) {
if (lines.length === 0) return null
return (
<section aria-label="lines" data-slot="trace-details-lines">
<h4 className={cn("m-0 mb-1", META, "text-foreground/40")}>
{lines.length} {lines.length === 1 ? "line" : "lines"}
</h4>
<ol className="m-0 grid list-none gap-px p-0">
{lines.map((l) => (
<li
key={l.id}
onPointerEnter={() => onHover(l.id)}
onPointerLeave={() => onHover(null)}
onFocus={() => onHover(l.id)}
onBlur={() => onHover(null)}
>
{onLine ? (
<button
type="button"
data-slot="trace-details-line"
data-chosen={l.id === line || undefined}
aria-current={l.id === line || undefined}
onClick={() => onLine(l.id)}
className={cn(
LINE_ROW,
"w-full cursor-pointer text-left hover:bg-foreground/6",
l.id === line && "bg-foreground/10",
)}
>
<LineWords line={l} from={span.from} />
</button>
) : (
<div data-slot="trace-details-line" className={LINE_ROW}>
<LineWords line={l} from={span.from} />
</div>
)}
</li>
))}
</ol>
</section>
)
}