diff --git a/apps/design-system/AGENTS.md b/apps/design-system/AGENTS.md
new file mode 100644
index 00000000000..643577dfaef
--- /dev/null
+++ b/apps/design-system/AGENTS.md
@@ -0,0 +1,9 @@
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/apps/design-system/CLAUDE.md b/apps/design-system/CLAUDE.md
new file mode 100644
index 00000000000..43c994c2d36
--- /dev/null
+++ b/apps/design-system/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts
index 4ad98ae7c13..e91140d52aa 100644
--- a/apps/design-system/config/docs.ts
+++ b/apps/design-system/config/docs.ts
@@ -70,6 +70,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/ui-patterns/charts',
items: [],
},
+ {
+ title: 'Tracing',
+ href: '/docs/ui-patterns/tracing',
+ items: [],
+ },
{
title: 'Connect Interstitials',
href: '/docs/ui-patterns/connect-interstitials',
diff --git a/apps/design-system/content/docs/ui-patterns/tracing.mdx b/apps/design-system/content/docs/ui-patterns/tracing.mdx
new file mode 100644
index 00000000000..999a01918ef
--- /dev/null
+++ b/apps/design-system/content/docs/ui-patterns/tracing.mdx
@@ -0,0 +1,40 @@
+---
+title: Tracing
+description: Composable primitives for distributed-trace views built on the shared design system
+---
+
+```tsx
+import { Trace } from 'ui-patterns/Trace'
+```
+
+## Ruler
+
+
+
+## Bar and Markers
+
+
+
+## Cell
+
+
+
+## Minimap
+
+
+
+## Inspector
+
+
+
+## Composition
+
+
+
+### Compact, read-only embed
+
+
+
+### Selection synced to the URL
+
+
diff --git a/apps/design-system/registry/default/example/trace-inspector-demo.tsx b/apps/design-system/registry/default/example/trace-inspector-demo.tsx
new file mode 100644
index 00000000000..e158f89eb06
--- /dev/null
+++ b/apps/design-system/registry/default/example/trace-inspector-demo.tsx
@@ -0,0 +1,45 @@
+import { useState } from 'react'
+import { ToggleGroup, ToggleGroupItem } from 'ui'
+import { Trace } from 'ui-patterns/Trace'
+
+import { createSampleTrace } from './trace-sample'
+
+const sample = createSampleTrace()
+
+const OPTIONS = [
+ { id: 'none', label: 'None' },
+ { id: 'span-0001', label: 'Root' },
+ { id: 'span-0008', label: 'Error' },
+ { id: 'span-0016', label: 'Running' },
+]
+
+export default function TraceInspectorDemo() {
+ const [selectedId, setSelectedId] = useState('span-0008')
+
+ return (
+
+ setSelectedId(value)}
+ allowDeselect={false}
+ aria-label="Inspected span"
+ >
+ {OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ setSelectedId(id ?? 'none')}
+ >
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/trace-minimap-demo.tsx b/apps/design-system/registry/default/example/trace-minimap-demo.tsx
new file mode 100644
index 00000000000..faf9df4e16b
--- /dev/null
+++ b/apps/design-system/registry/default/example/trace-minimap-demo.tsx
@@ -0,0 +1,40 @@
+import { useMemo, useState } from 'react'
+import { Slider, Toggle } from 'ui'
+import { Trace, type TimeWindow } from 'ui-patterns/Trace'
+
+import { createSampleTrace } from './trace-sample'
+
+export default function TraceMinimapDemo() {
+ const [isLarge, setIsLarge] = useState(false)
+ const sample = useMemo(() => createSampleTrace({ spanCount: isLarge ? 5000 : 0 }), [isLarge])
+ const [window, setWindow] = useState(undefined)
+
+ const start = sample.spans[0].startMs
+ const bounds: TimeWindow = [start, sample.nowMs]
+ const current = window ?? bounds
+
+ return (
+
+
+
+ 5,000 spans (density mode)
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/trace-ruler-demo.tsx b/apps/design-system/registry/default/example/trace-ruler-demo.tsx
new file mode 100644
index 00000000000..d193716734c
--- /dev/null
+++ b/apps/design-system/registry/default/example/trace-ruler-demo.tsx
@@ -0,0 +1,66 @@
+import { useState } from 'react'
+import { Slider } from 'ui'
+import { Trace, type Span, type TimeWindow } from 'ui-patterns/Trace'
+
+function traceOf(durationMs: number): { spans: Span[] } {
+ return {
+ spans: [
+ {
+ id: 'root',
+ parentId: null,
+ traceId: 'ruler',
+ name: 'root',
+ serviceName: 'svc',
+ kind: 'server',
+ startMs: 0,
+ endMs: durationMs,
+ status: 'ok',
+ attributes: {},
+ events: [],
+ links: [],
+ },
+ ],
+ }
+}
+
+const trace = traceOf(1000)
+
+export default function TraceRulerDemo() {
+ const [window, setWindow] = useState([0, 1000])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {[
+ { durationMs: 2, label: 'Sub-millisecond' },
+ { durationMs: 412, label: 'Request-sized' },
+ { durationMs: 95 * 60_000, label: 'Minutes' },
+ ].map(({ durationMs, label }) => (
+
+
+ {label}
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/trace-sample.ts b/apps/design-system/registry/default/example/trace-sample.ts
new file mode 100644
index 00000000000..149a95904bf
--- /dev/null
+++ b/apps/design-system/registry/default/example/trace-sample.ts
@@ -0,0 +1,347 @@
+import type { Span, SpanKind, SpanStatus } from 'ui-patterns/Trace'
+
+export interface SampleTraceOptions {
+ spanCount?: number
+ seed?: number
+ startMs?: number
+ includeOrphan?: boolean
+ includeRunning?: boolean
+ includeZeroDuration?: boolean
+ includeClockSkew?: boolean
+}
+
+export interface SampleTrace {
+ traceId: string
+ spans: Span[]
+ nowMs: number
+}
+
+export const SAMPLE_TRACE_ID = 'trace-4f2a9c1e7b3d5a60'
+export const SAMPLE_TRACE_START_MS = Date.UTC(2026, 0, 15, 9, 30, 0)
+
+function mulberry32(seed: number) {
+ let state = seed >>> 0
+ return () => {
+ state = (state + 0x6d2b79f5) >>> 0
+ let t = state
+ t = Math.imul(t ^ (t >>> 15), t | 1)
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+ }
+}
+
+function span(
+ id: string,
+ parentId: string | null,
+ name: string,
+ serviceName: string,
+ kind: SpanKind,
+ startMs: number,
+ endMs: number | null,
+ status: SpanStatus,
+ base: number,
+ extra: Partial> = {}
+): Span {
+ return {
+ id,
+ parentId,
+ traceId: SAMPLE_TRACE_ID,
+ name,
+ serviceName,
+ kind,
+ startMs: base + startMs,
+ endMs: endMs === null ? null : base + endMs,
+ status,
+ attributes: extra.attributes ?? {},
+ events: (extra.events ?? []).map((event) => ({ ...event, timeMs: base + event.timeMs })),
+ links: extra.links ?? [],
+ }
+}
+
+function baseSpans(base: number, options: Required): Span[] {
+ const spans: Span[] = [
+ span('span-0001', null, 'GET /api/orders', 'api-gateway', 'server', 0, 412, 'ok', base, {
+ attributes: {
+ 'http.method': 'GET',
+ 'http.route': '/api/orders',
+ 'http.status_code': 200,
+ 'http.url': 'https://api.example.com/api/orders?limit=50',
+ 'user.id': 'usr_8f3k2',
+ 'net.peer.ip': '10.0.3.14',
+ 'request.id': 'req_01HZX4M2',
+ },
+ events: [
+ { timeMs: 0.3, name: 'request.received' },
+ { timeMs: 12, name: 'log', attributes: { 'log.severity': 'INFO', message: 'cache miss' } },
+ ],
+ }),
+ span('span-0002', 'span-0001', 'authenticate', 'auth-service', 'client', 2, 38, 'ok', base, {
+ attributes: { 'auth.method': 'jwt', 'auth.cached': false, 'auth.token_expiry': 1768556400 },
+ }),
+ span('span-0003', 'span-0002', 'jwt.verify', 'auth-service', 'internal', 4, 31, 'ok', base, {
+ attributes: { 'jwt.alg': 'RS256', 'jwt.kid': 'k-2026-01' },
+ }),
+ span(
+ 'span-0004',
+ 'span-0001',
+ 'cache.get orders:usr_8f3k2',
+ 'redis-cache',
+ 'client',
+ 40,
+ 43,
+ 'ok',
+ base,
+ { attributes: { 'db.system': 'redis', 'cache.hit': false } }
+ ),
+ span('span-0005', 'span-0001', 'SELECT orders', 'postgres', 'client', 45, 210, 'ok', base, {
+ attributes: {
+ 'db.system': 'postgresql',
+ 'db.name': 'app',
+ 'db.statement':
+ 'SELECT id, total, status FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50',
+ 'db.rows': 50,
+ },
+ }),
+ span('span-0006', 'span-0005', 'pg.connect', 'postgres', 'internal', 45, 52, 'ok', base, {
+ attributes: { 'pool.waiting': 3, 'pool.size': 20 },
+ }),
+ span('span-0007', 'span-0005', 'pg.execute', 'postgres', 'internal', 52, 206, 'ok', base, {
+ events: [{ timeMs: 180, name: 'first_row' }],
+ }),
+ span(
+ 'span-0008',
+ 'span-0001',
+ 'POST /payments/authorize',
+ 'payment-service',
+ 'client',
+ 215,
+ 398,
+ 'error',
+ base,
+ {
+ attributes: {
+ 'http.method': 'POST',
+ 'http.status_code': 502,
+ 'error.message': 'upstream timeout after 150ms',
+ 'retry.count': 1,
+ 'payment.provider_response': {
+ code: 'TIMEOUT',
+ attempt: 2,
+ details: { region: 'eu-west-1', latency_ms: 150 },
+ },
+ },
+ events: [
+ {
+ timeMs: 395,
+ name: 'exception',
+ attributes: {
+ 'exception.type': 'UpstreamTimeoutError',
+ 'exception.message': 'upstream timeout after 150ms',
+ 'exception.stacktrace':
+ 'UpstreamTimeoutError: upstream timeout after 150ms\n at charge (payments/provider.ts:88:11)\n at authorize (payments/authorize.ts:42:5)',
+ },
+ },
+ ],
+ links: [{ traceId: 'trace-91cc0e4d2a7f1b38', spanId: 'span-77aa' }],
+ }
+ ),
+ span(
+ 'span-0009',
+ 'span-0008',
+ 'authorize',
+ 'payment-service',
+ 'server',
+ 218,
+ 392,
+ 'error',
+ base,
+ {
+ attributes: { 'payment.amount_cents': 12900, 'payment.currency': 'EUR' },
+ }
+ ),
+ span('span-0010', 'span-0009', 'fraud.check', 'fraud-service', 'client', 220, 268, 'ok', base, {
+ attributes: { 'fraud.model': 'v7', 'fraud.score': 0.12 },
+ events: [{ timeMs: 265, name: 'score.computed', attributes: { 'fraud.score': 0.12 } }],
+ }),
+ span(
+ 'span-0011',
+ 'span-0009',
+ 'provider.charge',
+ 'payment-service',
+ 'client',
+ 270,
+ 390,
+ 'error',
+ base,
+ { attributes: { 'provider.name': 'cards-eu', 'provider.timeout_ms': 150 } }
+ ),
+ span(
+ 'span-0012',
+ 'span-0011',
+ 'http.request',
+ 'provider-client',
+ 'client',
+ options.includeClockSkew ? 268 : 271,
+ 389,
+ 'error',
+ base,
+ {
+ attributes: {
+ 'http.method': 'POST',
+ 'http.url': 'https://cards-eu.example.net/v1/charges',
+ },
+ }
+ ),
+ span(
+ 'span-0014',
+ 'span-0001',
+ 'render.response',
+ 'api-gateway',
+ 'internal',
+ 402,
+ 411,
+ 'ok',
+ base,
+ {
+ attributes: { 'response.bytes': 18422, 'response.format': 'json' },
+ }
+ ),
+ ]
+
+ if (options.includeZeroDuration) {
+ spans.push(
+ span('span-0013', 'span-0001', 'cache.set', 'redis-cache', 'client', 400, 400, 'ok', base, {
+ attributes: { 'db.system': 'redis', 'cache.ttl_seconds': 60 },
+ })
+ )
+ }
+
+ if (options.includeOrphan) {
+ spans.push(
+ span(
+ 'span-0015',
+ 'span-missing-parent',
+ 'background.job',
+ 'worker-service',
+ 'consumer',
+ 300,
+ 360,
+ 'ok',
+ base,
+ { attributes: { 'messaging.system': 'pgmq', 'messaging.destination': 'order_events' } }
+ )
+ )
+ }
+
+ if (options.includeRunning) {
+ spans.push(
+ span(
+ 'span-0016',
+ 'span-0001',
+ 'webhook.dispatch',
+ 'webhook-service',
+ 'producer',
+ 405,
+ null,
+ 'unset',
+ base,
+ {
+ attributes: { 'webhook.url': 'https://hooks.example.com/orders', 'webhook.attempt': 1 },
+ }
+ )
+ )
+ }
+
+ return spans
+}
+
+const FILLER_NAMES = [
+ 'db.query',
+ 'cache.get',
+ 'http.request',
+ 'serialize',
+ 'validate',
+ 'deserialize',
+ 'queue.publish',
+ 'fn.invoke',
+ 'template.render',
+ 'policy.evaluate',
+]
+
+const FILLER_SERVICES = [
+ 'api-gateway',
+ 'auth-service',
+ 'postgres',
+ 'redis-cache',
+ 'payment-service',
+ 'fraud-service',
+ 'worker-service',
+ 'search-service',
+]
+
+export function createSampleTrace(options: SampleTraceOptions = {}): SampleTrace {
+ const resolved: Required = {
+ spanCount: options.spanCount ?? 0,
+ seed: options.seed ?? 42,
+ startMs: options.startMs ?? SAMPLE_TRACE_START_MS,
+ includeOrphan: options.includeOrphan ?? true,
+ includeRunning: options.includeRunning ?? true,
+ includeZeroDuration: options.includeZeroDuration ?? true,
+ includeClockSkew: options.includeClockSkew ?? true,
+ }
+
+ const base = resolved.startMs
+ const spans = baseSpans(base, resolved)
+ const nowMs = base + 430
+
+ if (resolved.spanCount > spans.length) {
+ const random = mulberry32(resolved.seed)
+ const depthOf = new Map()
+ const endOf = (candidate: Span) => candidate.endMs ?? nowMs
+ for (const existing of spans) depthOf.set(existing.id, 0)
+
+ const parentPool = spans.filter((candidate) => endOf(candidate) - candidate.startMs > 2)
+ let counter = spans.length + 1
+
+ while (spans.length < resolved.spanCount) {
+ const parent = parentPool[Math.floor(random() * parentPool.length)]
+ const parentDepth = depthOf.get(parent.id) ?? 0
+ const parentStart = parent.startMs
+ const parentDuration = Math.max(1, endOf(parent) - parentStart)
+ const start = parentStart + random() * parentDuration * 0.85
+ const maxDuration = Math.max(0.05, endOf(parent) - start)
+ const duration = Math.max(0.05, random() * maxDuration * 0.6)
+ const isError = random() < 0.03
+ const id = `span-${counter.toString(16).padStart(6, '0')}`
+ counter++
+
+ const child: Span = {
+ id,
+ parentId: parent.id,
+ traceId: SAMPLE_TRACE_ID,
+ name: FILLER_NAMES[Math.floor(random() * FILLER_NAMES.length)],
+ serviceName: FILLER_SERVICES[Math.floor(random() * FILLER_SERVICES.length)],
+ kind: 'internal',
+ startMs: start,
+ endMs: start + duration,
+ status: isError ? 'error' : 'ok',
+ attributes: { 'generated.index': counter, 'generated.depth': parentDepth + 1 },
+ events: isError
+ ? [
+ {
+ timeMs: start + duration * 0.9,
+ name: 'exception',
+ attributes: { 'exception.type': 'Error' },
+ },
+ ]
+ : [],
+ links: [],
+ }
+ spans.push(child)
+ depthOf.set(id, parentDepth + 1)
+ if (parentDepth + 1 < 12 && duration > 2) parentPool.push(child)
+ }
+ }
+
+ return { traceId: SAMPLE_TRACE_ID, spans, nowMs }
+}
diff --git a/apps/design-system/registry/default/example/trace-span-bar-demo.tsx b/apps/design-system/registry/default/example/trace-span-bar-demo.tsx
new file mode 100644
index 00000000000..f9d8880a617
--- /dev/null
+++ b/apps/design-system/registry/default/example/trace-span-bar-demo.tsx
@@ -0,0 +1,104 @@
+import { useState } from 'react'
+import { Slider, Toggle } from 'ui'
+import { Trace, useSpan, type Span, type TimeWindow } from 'ui-patterns/Trace'
+
+import { createSampleTrace } from './trace-sample'
+
+const sample = createSampleTrace()
+const root = sample.spans[0]
+const base = root.startMs
+
+function make(id: string, name: string, overrides: Partial = {}): Span {
+ return { ...root, id, parentId: null, name, status: 'ok', events: [], ...overrides }
+}
+
+const spans: Span[] = [
+ make('default', 'default'),
+ make('selected', 'selected'),
+ make('error', 'error status', { status: 'error' }),
+ make('unset', 'unset status', { status: 'unset' }),
+ make('running', 'running', { startMs: base + 300, endMs: null, status: 'unset' }),
+ make('zero', 'zero duration', { startMs: base + 200, endMs: base + 200 }),
+ make('markers', 'markers', {
+ startMs: base + 20,
+ endMs: base + 380,
+ events: [
+ { timeMs: base + 60, name: 'log', attributes: { 'log.severity': 'INFO' } },
+ { timeMs: base + 200, name: 'cache.warmed' },
+ { timeMs: base + 340, name: 'exception', attributes: { 'exception.type': 'TimeoutError' } },
+ ],
+ }),
+]
+
+const filteredSpans: Span[] = [
+ make('dim', 'dim (ancestor of a match)'),
+ make('match', 'match', { parentId: 'dim', startMs: base + 100, endMs: base + 250 }),
+]
+
+function Label() {
+ const { span } = useSpan()
+ return (
+
+ {span.name}
+
+ )
+}
+
+function Gallery() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default function TraceSpanBarDemo() {
+ const [isZoomed, setIsZoomed] = useState(false)
+ const [offset, setOffset] = useState(120)
+ const window: TimeWindow | undefined = isZoomed ? [base + offset, base + offset + 180] : undefined
+
+ return (
+
+
+
+ Zoom window (clips bars)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/trace-tree-cell-demo.tsx b/apps/design-system/registry/default/example/trace-tree-cell-demo.tsx
new file mode 100644
index 00000000000..37abc7b7f15
--- /dev/null
+++ b/apps/design-system/registry/default/example/trace-tree-cell-demo.tsx
@@ -0,0 +1,35 @@
+import { useState } from 'react'
+import { Slider } from 'ui'
+import { Trace } from 'ui-patterns/Trace'
+
+import { createSampleTrace } from './trace-sample'
+
+const sample = createSampleTrace()
+const defaultCollapsed = new Set(['span-0008'])
+
+export default function TraceTreeCellDemo() {
+ const [indent, setIndent] = useState(14)
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/trace-waterfall-compact-demo.tsx b/apps/design-system/registry/default/example/trace-waterfall-compact-demo.tsx
new file mode 100644
index 00000000000..252df2eb846
--- /dev/null
+++ b/apps/design-system/registry/default/example/trace-waterfall-compact-demo.tsx
@@ -0,0 +1,21 @@
+import { Trace } from 'ui-patterns/Trace'
+
+import { createSampleTrace } from './trace-sample'
+
+const sample = createSampleTrace({ includeOrphan: false, includeRunning: false })
+
+export default function TraceWaterfallCompactDemo() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/trace-waterfall-demo.tsx b/apps/design-system/registry/default/example/trace-waterfall-demo.tsx
new file mode 100644
index 00000000000..ee7adc60730
--- /dev/null
+++ b/apps/design-system/registry/default/example/trace-waterfall-demo.tsx
@@ -0,0 +1,48 @@
+import { useMemo, useState } from 'react'
+import { Toggle } from 'ui'
+import { Trace } from 'ui-patterns/Trace'
+
+import { createSampleTrace } from './trace-sample'
+
+export default function TraceWaterfallDemo() {
+ const [isLarge, setIsLarge] = useState(false)
+ const sample = useMemo(() => createSampleTrace({ spanCount: isLarge ? 100_000 : 0 }), [isLarge])
+ const [selectedId, setSelectedId] = useState('span-0008')
+
+ return (
+
+
+
+
+
+
+
+
+
+ 100k spans
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/trace-waterfall-url-sync-demo.tsx b/apps/design-system/registry/default/example/trace-waterfall-url-sync-demo.tsx
new file mode 100644
index 00000000000..c4db22f5440
--- /dev/null
+++ b/apps/design-system/registry/default/example/trace-waterfall-url-sync-demo.tsx
@@ -0,0 +1,54 @@
+import { useCallback, useSyncExternalStore } from 'react'
+import { Trace } from 'ui-patterns/Trace'
+
+import { createSampleTrace } from './trace-sample'
+
+const sample = createSampleTrace()
+const PARAM = 'span'
+
+function subscribe(callback: () => void) {
+ window.addEventListener('popstate', callback)
+ return () => window.removeEventListener('popstate', callback)
+}
+
+function readSpanParam() {
+ return new URLSearchParams(window.location.search).get(PARAM)
+}
+
+function useUrlSelection() {
+ const selectedId = useSyncExternalStore(subscribe, readSpanParam, () => null)
+ const setSelectedId = useCallback((id: string | null) => {
+ const url = new URL(window.location.href)
+ if (id) url.searchParams.set(PARAM, id)
+ else url.searchParams.delete(PARAM)
+ window.history.replaceState(window.history.state, '', url)
+ window.dispatchEvent(new PopStateEvent('popstate'))
+ }, [])
+ return { selectedId, setSelectedId }
+}
+
+export default function TraceWaterfallUrlSyncDemo() {
+ const { selectedId, setSelectedId } = useUrlSelection()
+
+ return (
+
+
+ ?{PARAM}={selectedId ?? '(none)'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts
index 0a47eeaa6c8..4f297ed94b2 100644
--- a/apps/design-system/registry/examples.ts
+++ b/apps/design-system/registry/examples.ts
@@ -1924,4 +1924,50 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/status-code-demo.tsx'],
},
+ {
+ name: 'trace-ruler-demo',
+ type: 'components:example',
+ registryDependencies: ['slider'],
+ files: ['example/trace-ruler-demo.tsx'],
+ },
+ {
+ name: 'trace-span-bar-demo',
+ type: 'components:example',
+ registryDependencies: ['slider', 'toggle'],
+ files: ['example/trace-span-bar-demo.tsx'],
+ },
+ {
+ name: 'trace-tree-cell-demo',
+ type: 'components:example',
+ registryDependencies: ['slider', 'toggle'],
+ files: ['example/trace-tree-cell-demo.tsx'],
+ },
+ {
+ name: 'trace-minimap-demo',
+ type: 'components:example',
+ registryDependencies: ['slider', 'toggle'],
+ files: ['example/trace-minimap-demo.tsx'],
+ },
+ {
+ name: 'trace-inspector-demo',
+ type: 'components:example',
+ registryDependencies: ['toggle-group'],
+ files: ['example/trace-inspector-demo.tsx'],
+ },
+ {
+ name: 'trace-waterfall-demo',
+ type: 'components:example',
+ registryDependencies: ['toggle'],
+ files: ['example/trace-waterfall-demo.tsx'],
+ },
+ {
+ name: 'trace-waterfall-compact-demo',
+ type: 'components:example',
+ files: ['example/trace-waterfall-compact-demo.tsx'],
+ },
+ {
+ name: 'trace-waterfall-url-sync-demo',
+ type: 'components:example',
+ files: ['example/trace-waterfall-url-sync-demo.tsx'],
+ },
]
diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json
index 8cdc6963cdc..87df11e70d0 100644
--- a/packages/ui-patterns/package.json
+++ b/packages/ui-patterns/package.json
@@ -710,6 +710,66 @@
"import": "./src/Toc/types.ts",
"types": "./src/Toc/types.ts"
},
+ "./Trace/Bar": {
+ "import": "./src/Trace/Bar.tsx",
+ "types": "./src/Trace/Bar.tsx"
+ },
+ "./Trace/Cell": {
+ "import": "./src/Trace/Cell.tsx",
+ "types": "./src/Trace/Cell.tsx"
+ },
+ "./Trace/Header": {
+ "import": "./src/Trace/Header.tsx",
+ "types": "./src/Trace/Header.tsx"
+ },
+ "./Trace/Inspector": {
+ "import": "./src/Trace/Inspector.tsx",
+ "types": "./src/Trace/Inspector.tsx"
+ },
+ "./Trace/Minimap": {
+ "import": "./src/Trace/Minimap.tsx",
+ "types": "./src/Trace/Minimap.tsx"
+ },
+ "./Trace/Trace": {
+ "import": "./src/Trace/Trace.tsx",
+ "types": "./src/Trace/Trace.tsx"
+ },
+ "./Trace/Waterfall": {
+ "import": "./src/Trace/Waterfall.tsx",
+ "types": "./src/Trace/Waterfall.tsx"
+ },
+ "./Trace/hooks/useBrush": {
+ "import": "./src/Trace/hooks/useBrush.ts",
+ "types": "./src/Trace/hooks/useBrush.ts"
+ },
+ "./Trace/hooks/useKeyboard": {
+ "import": "./src/Trace/hooks/useKeyboard.ts",
+ "types": "./src/Trace/hooks/useKeyboard.ts"
+ },
+ "./Trace/hooks/useSpan": {
+ "import": "./src/Trace/hooks/useSpan.ts",
+ "types": "./src/Trace/hooks/useSpan.ts"
+ },
+ "./Trace/hooks/useTimeScale": {
+ "import": "./src/Trace/hooks/useTimeScale.ts",
+ "types": "./src/Trace/hooks/useTimeScale.ts"
+ },
+ "./Trace/hooks/useVisibleRows": {
+ "import": "./src/Trace/hooks/useVisibleRows.ts",
+ "types": "./src/Trace/hooks/useVisibleRows.ts"
+ },
+ "./Trace": {
+ "import": "./src/Trace/index.ts",
+ "types": "./src/Trace/index.ts"
+ },
+ "./Trace/types": {
+ "import": "./src/Trace/types.ts",
+ "types": "./src/Trace/types.ts"
+ },
+ "./Trace/utils": {
+ "import": "./src/Trace/utils.ts",
+ "types": "./src/Trace/utils.ts"
+ },
"./TweetCard": {
"import": "./src/TweetCard/index.tsx",
"types": "./src/TweetCard/index.tsx"
@@ -773,6 +833,7 @@
"@supabase/sql-to-rest": "^0.1.6",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-table": "catalog:",
+ "@tanstack/react-virtual": "^3.13.24",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"common": "workspace:*",
diff --git a/packages/ui-patterns/src/Trace/Bar.tsx b/packages/ui-patterns/src/Trace/Bar.tsx
new file mode 100644
index 00000000000..432667b4f6f
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/Bar.tsx
@@ -0,0 +1,142 @@
+'use client'
+
+import { CircleAlert } from 'lucide-react'
+import type { ComponentProps } from 'react'
+import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+
+import { useSpan } from './hooks/useSpan'
+import { useData, useView } from './Trace'
+import type { MarkerKind, SpanEvent } from './types'
+import {
+ computeBarGeometry,
+ durationOf,
+ effectiveEndMs,
+ formatMs,
+ inferMarkerKind,
+ MARKER_KIND_LABEL,
+} from './utils'
+
+export function Bar({ className, style, children, ...props }: ComponentProps<'div'>) {
+ const { span, state, isRunning } = useSpan()
+ const { window } = useView()
+ const { nowMs } = useData()
+
+ const endMs = effectiveEndMs(span, isRunning ? Math.max(nowMs, window[1]) : nowMs)
+ const geometry = computeBarGeometry(span.startMs, endMs, window)
+ if (!geometry.isVisible) return null
+
+ return (
+
+ {children}
+
+ )
+}
+
+interface MarkerProps {
+ event: SpanEvent
+ kind: MarkerKind
+ offsetMs: number
+}
+
+function Marker({ event, kind, offsetMs }: MarkerProps) {
+ const { window, bounds } = useView()
+ const duration = window[1] - window[0]
+ if (!(duration > 0)) return null
+ const fraction = (event.timeMs - window[0]) / duration
+ if (fraction < 0 || fraction > 1) return null
+
+ const kindLabel = MARKER_KIND_LABEL[kind]
+
+ return (
+
+
+
+
+
+ {event.name}
+ · +{formatMs(offsetMs)}
+
+
+ )
+}
+
+export function Markers() {
+ const { span } = useSpan()
+ if (span.events.length === 0) return null
+
+ return (
+ <>
+ {span.events.map((event, index) => (
+
+ ))}
+ >
+ )
+}
+
+export function Duration({ className, ...props }: ComponentProps<'span'>) {
+ const { span, isRunning } = useSpan()
+ const { nowMs } = useData()
+ const duration = formatMs(durationOf(span, nowMs))
+
+ return (
+
+ {isRunning ? `${duration}…` : duration}
+
+ )
+}
diff --git a/packages/ui-patterns/src/Trace/Cell.tsx b/packages/ui-patterns/src/Trace/Cell.tsx
new file mode 100644
index 00000000000..1acbb470917
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/Cell.tsx
@@ -0,0 +1,119 @@
+'use client'
+
+import { ChevronRight, Unplug } from 'lucide-react'
+import type { ComponentProps } from 'react'
+import { Badge, Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+
+import { Duration } from './Bar'
+import { useSpan } from './hooks/useSpan'
+import { useView } from './Trace'
+
+export interface CellProps extends ComponentProps<'div'> {
+ indent?: number
+}
+
+const TOGGLE_SIZE = 18
+
+export function Cell({ indent = 14, className, style, ...props }: CellProps) {
+ const { row, span, readOnly } = useSpan()
+ const { dispatch } = useView()
+ const guideOffset = TOGGLE_SIZE / 2 + 4
+
+ return (
+
+ {row.guides.map((show, depth) =>
+ show ? (
+
+ ) : null
+ )}
+
+
+ {row.hasChildren && (
+
+ }
+ onClick={(event) => {
+ event.stopPropagation()
+ dispatch({ type: 'toggle', id: row.id })
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+ {span.name}
+
+
+
+ {span.name}
+ · {span.serviceName}
+
+
+
+
+ {span.serviceName}
+
+
+ {row.isOrphan && (
+
+
+
+
+
+
+ Parent span is missing from this trace
+
+ )}
+
+ {row.hiddenDescendantCount > 0 && (
+ +{row.hiddenDescendantCount}
+ )}
+
+
+
+ )
+}
diff --git a/packages/ui-patterns/src/Trace/Header.tsx b/packages/ui-patterns/src/Trace/Header.tsx
new file mode 100644
index 00000000000..98aa4a2e521
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/Header.tsx
@@ -0,0 +1,145 @@
+'use client'
+
+import { ChevronsDownUp, Maximize2, Search } from 'lucide-react'
+import type { ComponentProps } from 'react'
+import type { BadgeProps, ButtonProps } from 'ui'
+import { Badge, Button, cn, InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
+
+import { useData, useView } from './Trace'
+import type { SpanStatus } from './types'
+
+export function Header({ className, children, ...props }: ComponentProps<'div'>) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function Title({ className, children, ...props }: ComponentProps<'h2'>) {
+ const { roots, byId } = useData()
+ const root = roots.length > 0 ? byId.get(roots[0]) : undefined
+ return (
+
+ {children ?? root?.name ?? 'Trace'}
+
+ )
+}
+
+export const STATUS_BADGE_VARIANT: Record = {
+ ok: 'success',
+ error: 'destructive',
+ unset: 'default',
+}
+
+export function Status({ className, children, ...props }: Omit) {
+ const { roots, byId } = useData()
+ const root = roots.length > 0 ? byId.get(roots[0]) : undefined
+ const status: SpanStatus = root?.status ?? 'unset'
+ return (
+
+ {children ?? status}
+
+ )
+}
+
+export interface FilterProps extends Omit, 'value' | 'onChange' | 'size'> {
+ className?: string
+}
+
+export function Filter({
+ className,
+ placeholder = 'Filter spans',
+ onKeyDown,
+ ...props
+}: FilterProps) {
+ const { query, matchIds, dispatch } = useView()
+
+ return (
+
+
+
+
+ dispatch({ type: 'setQuery', query: event.target.value })}
+ onKeyDown={(event) => {
+ onKeyDown?.(event)
+ if (event.key === 'Escape' && query !== '' && !event.defaultPrevented) {
+ event.preventDefault()
+ dispatch({ type: 'setQuery', query: '' })
+ }
+ }}
+ {...props}
+ />
+ {matchIds && (
+
+
+ {matchIds.size}
+
+
+ )}
+
+ )
+}
+
+export function CollapseAll({ children, onClick, ...props }: ButtonProps) {
+ const { dispatch } = useView()
+ return (
+ }
+ data-trace-collapse-all
+ onClick={(event) => {
+ onClick?.(event)
+ if (!event.defaultPrevented) dispatch({ type: 'collapseAll' })
+ }}
+ {...props}
+ >
+ {children ?? 'Collapse all'}
+
+ )
+}
+
+export function ResetZoom({ children, onClick, ...props }: ButtonProps) {
+ const { isReset, dispatch } = useView()
+ return (
+ }
+ disabled={isReset}
+ data-trace-reset-zoom
+ onClick={(event) => {
+ onClick?.(event)
+ if (!event.defaultPrevented) dispatch({ type: 'setWindow', window: null })
+ }}
+ {...props}
+ >
+ {children ?? 'Reset zoom'}
+
+ )
+}
diff --git a/packages/ui-patterns/src/Trace/Inspector.tsx b/packages/ui-patterns/src/Trace/Inspector.tsx
new file mode 100644
index 00000000000..ea72f8b79df
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/Inspector.tsx
@@ -0,0 +1,379 @@
+'use client'
+
+import { ChevronRight, CircleAlert, Copy, CornerDownRight, ExternalLink } from 'lucide-react'
+import type { ComponentProps, ReactNode } from 'react'
+import { toast } from 'sonner'
+import {
+ Badge,
+ Button,
+ cn,
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+ copyToClipboard,
+ ScrollArea,
+ Table,
+ TableBody,
+ TableCell,
+ TableRow,
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from 'ui'
+
+import { STATUS_BADGE_VARIANT } from './Header'
+import { useData, useView } from './Trace'
+import type { Span, ValueKind } from './types'
+import {
+ detectValueKind,
+ durationOf,
+ formatMs,
+ inferMarkerKind,
+ sortedEntries,
+ stringifyValue,
+ summarizeJson,
+} from './utils'
+
+function copy(text: string) {
+ copyToClipboard(text, () => toast.success('Copied to clipboard'))
+}
+
+interface ValueProps {
+ entryKey: string
+ value: unknown
+ kind: ValueKind
+}
+
+function Value({ entryKey, value, kind }: ValueProps) {
+ const { byId } = useData()
+ const { dispatch } = useView()
+
+ switch (kind) {
+ case 'null':
+ return null
+ case 'boolean':
+ return (
+ {String(value)}
+ )
+ case 'number':
+ return {stringifyValue(value)}
+ case 'id': {
+ const id = String(value)
+ if (!byId.has(id) || /trace/i.test(entryKey)) return {id}
+ return (
+ }
+ onClick={() => dispatch({ type: 'select', id })}
+ >
+ {id}
+
+ )
+ }
+ case 'url': {
+ const href = String(value)
+ return (
+
+ {href}
+
+
+ )
+ }
+ case 'error':
+ return (
+
+ {String(value)}
+
+ )
+ case 'json':
+ return (
+
+
+
+ }
+ >
+ {summarizeJson(value)}
+
+
+
+
+ {stringifyValue(value)}
+
+
+
+ )
+ default:
+ return {String(value)}
+ }
+}
+
+interface KeyValueProps {
+ data: Record
+ emptyMessage?: ReactNode
+ className?: string
+}
+
+function KeyValue({ data, emptyMessage = 'No attributes', className }: KeyValueProps) {
+ const entries = sortedEntries(data)
+
+ if (entries.length === 0) {
+ return (
+
+ {emptyMessage}
+
+ )
+ }
+
+ return (
+
+
+
+ {entries.map(([key, value]) => {
+ const kind = detectValueKind(key, value)
+ return (
+
+
+ {key}
+
+
+
+
+
+
+
+
+ }
+ onClick={() => copy(stringifyValue(value))}
+ />
+
+ Copy value
+
+
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+interface SectionProps {
+ title: ReactNode
+ count?: number
+ children: ReactNode
+}
+
+function Section({ title, count, children }: SectionProps) {
+ return (
+
+
+ {title}
+ {count !== undefined && (
+ {count}
+ )}
+
+ {children}
+
+ )
+}
+
+function SpanHeader({ span }: { span: Span }) {
+ const { nowMs, bounds } = useData()
+ const isRunning = span.endMs === null
+ const duration = formatMs(durationOf(span, nowMs))
+
+ return (
+
+ )
+}
+
+function Events({ span }: { span: Span }) {
+ return (
+
+ {span.events.length === 0 && (
+ No events
+ )}
+ {span.events.length > 0 && (
+
+ {span.events.map((event, index) => {
+ const kind = inferMarkerKind(event)
+ const hasAttributes = event.attributes && Object.keys(event.attributes).length > 0
+ return (
+ -
+
+
+ +{formatMs(event.timeMs - span.startMs)}
+
+ {kind === 'exception' && (
+
+ )}
+
+ {event.name}
+
+
+ {hasAttributes && }
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+function Links({ span }: { span: Span }) {
+ const { byId } = useData()
+ const { dispatch } = useView()
+ if (span.links.length === 0) return null
+
+ return (
+
+
+ {span.links.map((link) => (
+ -
+ trace
+ {link.traceId}
+ span
+ {byId.has(link.spanId) ? (
+ }
+ onClick={() => dispatch({ type: 'select', id: link.spanId })}
+ >
+ {link.spanId}
+
+ ) : (
+ {link.spanId}
+ )}
+
+ ))}
+
+
+ )
+}
+
+export interface InspectorProps extends ComponentProps<'aside'> {
+ spanId?: string
+ emptyMessage?: ReactNode
+}
+
+export function Inspector({ spanId, emptyMessage, className, ...props }: InspectorProps) {
+ const data = useData()
+ const { selectedId } = useView()
+ const resolvedId = spanId ?? selectedId
+ const span = resolvedId !== null ? (data.byId.get(resolvedId) ?? null) : null
+
+ return (
+
+ )
+}
diff --git a/packages/ui-patterns/src/Trace/Minimap.tsx b/packages/ui-patterns/src/Trace/Minimap.tsx
new file mode 100644
index 00000000000..035362921f2
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/Minimap.tsx
@@ -0,0 +1,226 @@
+'use client'
+
+import type { ComponentProps } from 'react'
+import { useMemo } from 'react'
+import { cn } from 'ui'
+
+import { useBrush } from './hooks/useBrush'
+import { useTimeScale } from './hooks/useTimeScale'
+import { useData, useView } from './Trace'
+import type { SpanStatus, TimeWindow } from './types'
+import { effectiveEndMs, formatMs, msToFraction } from './utils'
+
+export interface MinimapProps extends ComponentProps<'div'> {
+ height?: number
+ maxDetailSpans?: number
+ laneHeight?: number
+}
+
+const VIEWBOX_WIDTH = 1000
+
+interface DensityBucket {
+ x: number
+ total: number
+ errors: number
+}
+
+interface MinimapItem {
+ id: string
+ startMs: number
+ endMs: number
+ status: SpanStatus
+ depth: number
+}
+
+function computeDensity(
+ items: readonly MinimapItem[],
+ bounds: TimeWindow,
+ bucketCount: number
+): DensityBucket[] {
+ const duration = bounds[1] - bounds[0]
+ if (!(duration > 0) || bucketCount <= 0) return []
+ const total = new Array(bucketCount + 1).fill(0)
+ const errors = new Array(bucketCount + 1).fill(0)
+ for (const item of items) {
+ const from = Math.max(0, Math.floor(((item.startMs - bounds[0]) / duration) * bucketCount))
+ const to = Math.min(
+ bucketCount - 1,
+ Math.max(from, Math.ceil(((item.endMs - bounds[0]) / duration) * bucketCount) - 1)
+ )
+ total[from] += 1
+ total[to + 1] -= 1
+ if (item.status === 'error') {
+ errors[from] += 1
+ errors[to + 1] -= 1
+ }
+ }
+ const buckets: DensityBucket[] = []
+ let runningTotal = 0
+ let runningErrors = 0
+ for (let i = 0; i < bucketCount; i++) {
+ runningTotal += total[i]
+ runningErrors += errors[i]
+ buckets.push({ x: i / bucketCount, total: runningTotal, errors: runningErrors })
+ }
+ return buckets
+}
+
+const STATUS_FILL: Record = {
+ ok: 'fill-brand',
+ error: 'fill-destructive',
+ unset: 'fill-foreground-muted',
+}
+
+export function Minimap({
+ height = 48,
+ maxDetailSpans = 2000,
+ laneHeight = 3,
+ className,
+ ...props
+}: MinimapProps) {
+ const index = useData()
+ const { window, bounds, dispatch } = useView()
+ const { ref, width } = useTimeScale()
+ const brush = useBrush({
+ bounds,
+ window,
+ width,
+ onWindowChange: (next) => dispatch({ type: 'setWindow', window: next }),
+ onReset: () => dispatch({ type: 'setWindow', window: null }),
+ })
+
+ const isDetail = index.byId.size <= maxDetailSpans
+ const maxLanes = Math.max(1, Math.floor(height / laneHeight))
+
+ const shapes = useMemo(() => {
+ const items: MinimapItem[] = Array.from(index.byId.values()).map((span) => ({
+ id: span.id,
+ startMs: span.startMs,
+ endMs: effectiveEndMs(span, index.nowMs),
+ status: span.status,
+ depth: index.depthOf.get(span.id) ?? 0,
+ }))
+ if (isDetail) return { kind: 'detail' as const, items }
+ const bucketCount = Math.max(50, Math.min(400, Math.floor(width / 2) || 200))
+ return { kind: 'density' as const, buckets: computeDensity(items, bounds, bucketCount) }
+ }, [index, isDetail, bounds, width])
+
+ const maxDensity =
+ shapes.kind === 'density' ? Math.max(1, ...shapes.buckets.map((bucket) => bucket.total)) : 1
+
+ const leftShade = msToFraction(window[0], bounds)
+ const rightShade = 1 - msToFraction(window[1], bounds)
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/packages/ui-patterns/src/Trace/Trace.tsx b/packages/ui-patterns/src/Trace/Trace.tsx
new file mode 100644
index 00000000000..4837f4b5331
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/Trace.tsx
@@ -0,0 +1,172 @@
+'use client'
+
+import { createContext, use, useCallback, useMemo, useReducer, useRef } from 'react'
+
+import type {
+ DataContextValue,
+ RootProps,
+ TimeWindow,
+ ViewAction,
+ ViewContextValue,
+ ViewState,
+} from './types'
+import { buildTraceIndex, clampWindow, computeMatchIds, isFullWindow } from './utils'
+
+export const DataContext = createContext(null)
+export const ViewContext = createContext(null)
+
+const EMPTY_SET: ReadonlySet = new Set()
+
+function viewReducer(state: ViewState, action: ViewAction): ViewState {
+ switch (action.type) {
+ case 'setWindow':
+ return state.window === action.window ? state : { ...state, window: action.window }
+ case 'select':
+ return state.selectedId === action.id ? state : { ...state, selectedId: action.id }
+ case 'hover':
+ return state.hoveredId === action.id ? state : { ...state, hoveredId: action.id }
+ case 'toggle': {
+ const collapsed = new Set(state.collapsed)
+ if (collapsed.has(action.id)) collapsed.delete(action.id)
+ else collapsed.add(action.id)
+ return { ...state, collapsed }
+ }
+ case 'expand': {
+ if (!state.collapsed.has(action.id)) return state
+ const collapsed = new Set(state.collapsed)
+ collapsed.delete(action.id)
+ return { ...state, collapsed }
+ }
+ case 'collapse': {
+ if (state.collapsed.has(action.id)) return state
+ const collapsed = new Set(state.collapsed)
+ collapsed.add(action.id)
+ return { ...state, collapsed }
+ }
+ case 'expandAll':
+ return state.collapsed.size === 0 ? state : { ...state, collapsed: EMPTY_SET }
+ case 'setCollapsed':
+ return { ...state, collapsed: action.collapsed }
+ case 'setQuery':
+ return state.query === action.query ? state : { ...state, query: action.query }
+ case 'collapseAll':
+ return state
+ default:
+ return state
+ }
+}
+
+export function Root({
+ trace,
+ selectedId: selectedIdProp,
+ onSelectedIdChange,
+ window: windowProp,
+ onWindowChange,
+ defaultCollapsed = EMPTY_SET,
+ defaultQuery = '',
+ children,
+}: RootProps) {
+ const index = useMemo(() => buildTraceIndex(trace.spans, trace.nowMs), [trace.spans, trace.nowMs])
+
+ const [state, rawDispatch] = useReducer(
+ viewReducer,
+ null,
+ (): ViewState => ({
+ window: null,
+ selectedId: null,
+ hoveredId: null,
+ collapsed: defaultCollapsed,
+ query: defaultQuery,
+ })
+ )
+
+ const bounds = index.bounds
+ const isWindowControlled = windowProp !== undefined
+ const isSelectionControlled = selectedIdProp !== undefined
+
+ const latest = useRef({
+ index,
+ onSelectedIdChange,
+ onWindowChange,
+ isWindowControlled,
+ isSelectionControlled,
+ })
+ latest.current = {
+ index,
+ onSelectedIdChange,
+ onWindowChange,
+ isWindowControlled,
+ isSelectionControlled,
+ }
+
+ const dispatch = useCallback((action: ViewAction) => {
+ const current = latest.current
+ switch (action.type) {
+ case 'select':
+ current.onSelectedIdChange?.(action.id)
+ if (!current.isSelectionControlled) rawDispatch(action)
+ return
+ case 'setWindow': {
+ const b = current.index.bounds
+ const next: TimeWindow = action.window === null ? b : clampWindow(action.window, b)
+ current.onWindowChange?.(next)
+ if (!current.isWindowControlled) {
+ rawDispatch({ type: 'setWindow', window: isFullWindow(next, b) ? null : next })
+ }
+ return
+ }
+ case 'collapseAll': {
+ const parents = new Set()
+ for (const [id, children] of current.index.childrenOf) {
+ if (children.length > 0) parents.add(id)
+ }
+ rawDispatch({ type: 'setCollapsed', collapsed: parents })
+ return
+ }
+ default:
+ rawDispatch(action)
+ }
+ }, [])
+
+ const window = useMemo(() => {
+ const stored = isWindowControlled ? windowProp : state.window
+ if (!stored) return bounds
+ return clampWindow(stored, bounds)
+ }, [isWindowControlled, windowProp, state.window, bounds])
+
+ const selectedId = isSelectionControlled ? (selectedIdProp ?? null) : state.selectedId
+ const matchIds = useMemo(() => computeMatchIds(index, state.query), [index, state.query])
+
+ const view = useMemo(
+ () => ({
+ window,
+ bounds,
+ isReset: isFullWindow(window, bounds),
+ selectedId,
+ hoveredId: state.hoveredId,
+ collapsed: state.collapsed,
+ query: state.query,
+ matchIds,
+ dispatch,
+ }),
+ [window, bounds, selectedId, state.hoveredId, state.collapsed, state.query, matchIds, dispatch]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useData(): DataContextValue {
+ const value = use(DataContext)
+ if (!value) throw new Error('Trace parts must be rendered inside Trace.Root')
+ return value
+}
+
+export function useView(): ViewContextValue {
+ const value = use(ViewContext)
+ if (!value) throw new Error('Trace parts must be rendered inside Trace.Root')
+ return value
+}
diff --git a/packages/ui-patterns/src/Trace/Waterfall.tsx b/packages/ui-patterns/src/Trace/Waterfall.tsx
new file mode 100644
index 00000000000..82dc7102240
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/Waterfall.tsx
@@ -0,0 +1,294 @@
+'use client'
+
+import { useVirtualizer } from '@tanstack/react-virtual'
+import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
+import type { ComponentProps, CSSProperties, KeyboardEvent, ReactNode } from 'react'
+import { createContext, use, useCallback, useEffect, useMemo, useRef } from 'react'
+import { cn, ScrollBar, ScrollViewport } from 'ui'
+
+import { useKeyboard } from './hooks/useKeyboard'
+import { SpanContext } from './hooks/useSpan'
+import { useTimeScale } from './hooks/useTimeScale'
+import { useVisibleRows } from './hooks/useVisibleRows'
+import { useView } from './Trace'
+import type { BarState, SpanContextValue, VisibleRow } from './types'
+
+export interface WaterfallProps extends ComponentProps<'div'> {
+ treeWidth?: number
+}
+
+export function Waterfall({
+ treeWidth = 320,
+ className,
+ style,
+ children,
+ ...props
+}: WaterfallProps) {
+ const vars = { '--trace-tree-width': `${treeWidth}px` } as CSSProperties
+ return (
+
+ {children}
+
+ )
+}
+
+export interface RulerProps extends ComponentProps<'div'> {
+ targetTickSpacing?: number
+}
+
+export function Ruler({ targetTickSpacing = 80, className, children, ...props }: RulerProps) {
+ const { ref, scale } = useTimeScale({ targetTickSpacing })
+
+ return (
+
+
+ {children ?? 'Span'}
+
+
+ {scale.ticks.map((tick) => (
+
+
+ {tick.label}
+
+
+ ))}
+
+
+ )
+}
+
+interface RowsContextValue {
+ readOnly: boolean
+ focusableId: string | null
+}
+
+const RowsContext = createContext({ readOnly: false, focusableId: null })
+
+interface RowProps {
+ row: VisibleRow
+ children: ReactNode
+}
+
+function Row({ row, children }: RowProps) {
+ const { selectedId, hoveredId, dispatch } = useView()
+ const { readOnly, focusableId } = use(RowsContext)
+
+ const { span } = row
+ const isSelected = !readOnly && row.id === selectedId
+ const isHovered = !readOnly && row.id === hoveredId
+ const isRunning = span.endMs === null
+ const isDim = row.filterMatch === 'ancestor'
+
+ let state: BarState = 'default'
+ if (isSelected) state = 'selected'
+ else if (isHovered) state = 'hover'
+ else if (isDim) state = 'dim'
+ else if (isRunning) state = 'running'
+
+ const value = useMemo(
+ () => ({ span, row, isSelected, isHovered, isRunning, state, readOnly }),
+ [span, row, isSelected, isHovered, isRunning, state, readOnly]
+ )
+
+ return (
+
+ {
+ if (!readOnly) dispatch({ type: 'select', id: row.id })
+ }}
+ onPointerEnter={() => {
+ if (!readOnly) dispatch({ type: 'hover', id: row.id })
+ }}
+ onPointerLeave={() => {
+ if (!readOnly) dispatch({ type: 'hover', id: null })
+ }}
+ >
+ {children}
+
+
+ )
+}
+
+export interface RowsProps extends ComponentProps {
+ rowHeight?: number
+ overscan?: number
+ readOnly?: boolean
+ emptyMessage?: ReactNode
+}
+
+export function Rows({
+ rowHeight = 28,
+ overscan = 8,
+ readOnly = false,
+ emptyMessage,
+ children,
+ className,
+ 'aria-label': ariaLabel = 'Spans',
+ ...props
+}: RowsProps) {
+ const { rows, rowIndexById, totalCount, matchCount } = useVisibleRows()
+ const { selectedId, dispatch } = useView()
+
+ const viewportRef = useRef(null)
+ const treeRef = useRef(null)
+ const pendingFocus = useRef(false)
+
+ const virtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => viewportRef.current,
+ estimateSize: () => rowHeight,
+ overscan,
+ getItemKey: (index) => rows[index].id,
+ })
+
+ const keyboard = useKeyboard({ rows, rowIndexById, selectedId, dispatch, disabled: readOnly })
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ pendingFocus.current = true
+ keyboard.onKeyDown(event)
+ },
+ [keyboard]
+ )
+
+ useEffect(() => {
+ if (readOnly || selectedId === null) return
+ const index = rowIndexById.get(selectedId)
+ if (index === undefined) return
+ virtualizer.scrollToIndex(index, { align: 'auto' })
+ }, [selectedId, rowIndexById, virtualizer, readOnly])
+
+ useEffect(() => {
+ if (!pendingFocus.current || selectedId === null) return
+ pendingFocus.current = false
+ let frame = 0
+ const focusRow = (attempt: number) => {
+ const tree = treeRef.current
+ if (!tree) return
+ const target = tree.querySelector(
+ `[data-trace-row-id="${CSS.escape(selectedId)}"]`
+ )
+ if (target) {
+ target.focus({ preventScroll: true })
+ return
+ }
+ if (attempt < 3) frame = requestAnimationFrame(() => focusRow(attempt + 1))
+ }
+ focusRow(0)
+ return () => cancelAnimationFrame(frame)
+ }, [selectedId])
+
+ const rowsContext = useMemo(
+ () => ({ readOnly, focusableId: readOnly ? null : (selectedId ?? rows[0]?.id ?? null) }),
+ [readOnly, selectedId, rows]
+ )
+
+ const vars = { '--trace-row-height': `${rowHeight}px` } as CSSProperties
+
+ let fallback = emptyMessage ?? 'No spans in this trace'
+ if (emptyMessage === undefined && totalCount > 0 && matchCount === 0) {
+ fallback = 'No spans match this filter'
+ }
+
+ return (
+
+
+
+ {rows.length === 0 && (
+
+ {fallback}
+
+ )}
+
+ {virtualizer.getVirtualItems().map((item) => {
+ const row = rows[item.index]
+ if (!row) return null
+ return (
+
+ {children}
+
+ )
+ })}
+
+
+
+
+
+
+ )
+}
+
+export function Lane({ className, children, ...props }: ComponentProps<'div'>) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/packages/ui-patterns/src/Trace/hooks/useBrush.ts b/packages/ui-patterns/src/Trace/hooks/useBrush.ts
new file mode 100644
index 00000000000..0657e671c33
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/hooks/useBrush.ts
@@ -0,0 +1,250 @@
+import type { KeyboardEvent, PointerEvent } from 'react'
+import { useCallback, useMemo, useRef, useState } from 'react'
+
+import type { BrushEdge, BrushMode, BrushRect, TimeWindow } from '../types'
+import { brushRect, isFullWindow, panWindow, pxToMs, resizeWindow, windowFromDrag } from '../utils'
+
+export interface UseBrushOptions {
+ bounds: TimeWindow
+ window: TimeWindow
+ width: number
+ onWindowChange: (window: TimeWindow) => void
+ onReset?: () => void
+ minDurationMs?: number
+ clickThresholdPx?: number
+ keyboardStepFraction?: number
+}
+
+interface DragState {
+ mode: BrushMode
+ pointerId: number
+ originX: number
+ trackLeft: number
+ originWindow: TimeWindow
+ moved: boolean
+}
+
+export interface BrushTrackProps {
+ onPointerDown: (event: PointerEvent) => void
+ onPointerMove: (event: PointerEvent) => void
+ onPointerUp: (event: PointerEvent) => void
+ onPointerCancel: (event: PointerEvent) => void
+ onDoubleClick: () => void
+}
+
+export interface BrushBodyProps extends Omit {
+ onKeyDown: (event: KeyboardEvent) => void
+ tabIndex: number
+}
+
+export interface BrushHandleProps extends Omit {
+ onKeyDown: (event: KeyboardEvent) => void
+ role: 'slider'
+ tabIndex: number
+ 'aria-valuemin': number
+ 'aria-valuemax': number
+ 'aria-valuenow': number
+ 'aria-orientation': 'horizontal'
+}
+
+export interface UseBrushResult {
+ rect: BrushRect
+ mode: BrushMode
+ isDragging: boolean
+ isFull: boolean
+ getTrackProps: () => BrushTrackProps
+ getBrushProps: () => BrushBodyProps
+ getHandleProps: (edge: BrushEdge) => BrushHandleProps
+}
+
+export function useBrush(options: UseBrushOptions): UseBrushResult {
+ const {
+ bounds,
+ window,
+ width,
+ onWindowChange,
+ onReset,
+ minDurationMs,
+ clickThresholdPx = 3,
+ keyboardStepFraction = 0.05,
+ } = options
+
+ const [mode, setMode] = useState('idle')
+ const drag = useRef(null)
+
+ const latest = useRef({ bounds, window, width, onWindowChange, onReset, minDurationMs })
+ latest.current = { bounds, window, width, onWindowChange, onReset, minDurationMs }
+
+ const reset = useCallback(() => {
+ const { onReset: handleReset, onWindowChange: change, bounds: b } = latest.current
+ if (handleReset) handleReset()
+ else change([b[0], b[1]])
+ }, [])
+
+ const begin = useCallback((event: PointerEvent, nextMode: BrushMode) => {
+ if (event.button !== 0) return
+ const track = event.currentTarget.closest('[data-trace-brush-track]')
+ const trackLeft = (track ?? event.currentTarget).getBoundingClientRect().left
+ event.currentTarget.setPointerCapture(event.pointerId)
+ drag.current = {
+ mode: nextMode,
+ pointerId: event.pointerId,
+ originX: event.clientX - trackLeft,
+ trackLeft,
+ originWindow: latest.current.window,
+ moved: false,
+ }
+ setMode(nextMode)
+ }, [])
+
+ const move = useCallback((event: PointerEvent) => {
+ const state = drag.current
+ if (!state || state.pointerId !== event.pointerId) return
+ const { bounds: b, width: w, onWindowChange: change, minDurationMs: min } = latest.current
+ const x = event.clientX - state.trackLeft
+ const dx = x - state.originX
+ if (Math.abs(dx) >= 1) state.moved = true
+ if (!state.moved) return
+
+ switch (state.mode) {
+ case 'create':
+ change(windowFromDrag(pxToMs(state.originX, b, w), pxToMs(x, b, w), b, min))
+ break
+ case 'move': {
+ const deltaMs = pxToMs(dx, b, w) - b[0]
+ change(panWindow(state.originWindow, deltaMs, b))
+ break
+ }
+ case 'resize-start':
+ change(resizeWindow(state.originWindow, 'start', pxToMs(x, b, w), b, min))
+ break
+ case 'resize-end':
+ change(resizeWindow(state.originWindow, 'end', pxToMs(x, b, w), b, min))
+ break
+ default:
+ break
+ }
+ }, [])
+
+ const end = useCallback(
+ (event: PointerEvent) => {
+ const state = drag.current
+ if (!state || state.pointerId !== event.pointerId) return
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId)
+ }
+ const { bounds: b, width: w, window: current, onWindowChange: change } = latest.current
+ const x = event.clientX - state.trackLeft
+ const isClick = Math.abs(x - state.originX) < clickThresholdPx
+ if (state.mode === 'create' && isClick && !isFullWindow(current, b)) {
+ const size = current[1] - current[0]
+ change(panWindow(current, pxToMs(x, b, w) - size / 2 - current[0], b))
+ }
+ drag.current = null
+ setMode('idle')
+ },
+ [clickThresholdPx]
+ )
+
+ const sharedPointerProps = useMemo(
+ () => ({ onPointerMove: move, onPointerUp: end, onPointerCancel: end }),
+ [move, end]
+ )
+
+ const getTrackProps = useCallback(
+ (): BrushTrackProps => ({
+ onPointerDown: (event) => {
+ if (event.target !== event.currentTarget) return
+ begin(event, 'create')
+ },
+ ...sharedPointerProps,
+ onDoubleClick: reset,
+ }),
+ [begin, sharedPointerProps, reset]
+ )
+
+ const keyboardPan = useCallback(
+ (direction: -1 | 1) => {
+ const { bounds: b, window: current, onWindowChange: change } = latest.current
+ const step = (current[1] - current[0]) * keyboardStepFraction * direction
+ change(panWindow(current, step, b))
+ },
+ [keyboardStepFraction]
+ )
+
+ const keyboardResize = useCallback(
+ (edge: BrushEdge, direction: -1 | 1) => {
+ const {
+ bounds: b,
+ window: current,
+ onWindowChange: change,
+ minDurationMs: min,
+ } = latest.current
+ const step = (b[1] - b[0]) * keyboardStepFraction * direction
+ const ms = (edge === 'start' ? current[0] : current[1]) + step
+ change(resizeWindow(current, edge, ms, b, min))
+ },
+ [keyboardStepFraction]
+ )
+
+ const getBrushProps = useCallback(
+ (): BrushBodyProps => ({
+ onPointerDown: (event) => {
+ if (event.target !== event.currentTarget) return
+ event.stopPropagation()
+ begin(event, 'move')
+ },
+ ...sharedPointerProps,
+ onKeyDown: (event) => {
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault()
+ keyboardPan(-1)
+ } else if (event.key === 'ArrowRight') {
+ event.preventDefault()
+ keyboardPan(1)
+ } else if (event.key === 'Escape' || event.key === 'Home') {
+ event.preventDefault()
+ reset()
+ }
+ },
+ tabIndex: 0,
+ }),
+ [begin, sharedPointerProps, keyboardPan, reset]
+ )
+
+ const getHandleProps = useCallback(
+ (edge: BrushEdge): BrushHandleProps => ({
+ onPointerDown: (event) => {
+ event.stopPropagation()
+ begin(event, edge === 'start' ? 'resize-start' : 'resize-end')
+ },
+ ...sharedPointerProps,
+ onKeyDown: (event) => {
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault()
+ keyboardResize(edge, -1)
+ } else if (event.key === 'ArrowRight') {
+ event.preventDefault()
+ keyboardResize(edge, 1)
+ }
+ },
+ role: 'slider',
+ tabIndex: 0,
+ 'aria-valuemin': bounds[0],
+ 'aria-valuemax': bounds[1],
+ 'aria-valuenow': edge === 'start' ? window[0] : window[1],
+ 'aria-orientation': 'horizontal',
+ }),
+ [begin, sharedPointerProps, keyboardResize, bounds, window]
+ )
+
+ return {
+ rect: brushRect(window, bounds, width),
+ mode,
+ isDragging: mode !== 'idle',
+ isFull: isFullWindow(window, bounds),
+ getTrackProps,
+ getBrushProps,
+ getHandleProps,
+ }
+}
diff --git a/packages/ui-patterns/src/Trace/hooks/useKeyboard.ts b/packages/ui-patterns/src/Trace/hooks/useKeyboard.ts
new file mode 100644
index 00000000000..994731ac897
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/hooks/useKeyboard.ts
@@ -0,0 +1,44 @@
+import type { Dispatch, KeyboardEvent } from 'react'
+import { useCallback } from 'react'
+
+import type { KeyboardAction, ViewAction, VisibleRow } from '../types'
+import { NAVIGATION_KEYS, resolveKeyboardAction } from '../utils'
+
+export interface UseKeyboardOptions {
+ rows: readonly VisibleRow[]
+ rowIndexById: ReadonlyMap
+ selectedId: string | null
+ dispatch: Dispatch
+ disabled?: boolean
+}
+
+export interface UseKeyboardResult {
+ onKeyDown: (event: KeyboardEvent) => void
+ press: (key: string) => KeyboardAction | null
+}
+
+export function useKeyboard(options: UseKeyboardOptions): UseKeyboardResult {
+ const { rows, rowIndexById, selectedId, dispatch, disabled = false } = options
+
+ const press = useCallback(
+ (key: string): KeyboardAction | null => {
+ if (disabled) return null
+ const action = resolveKeyboardAction({ key, rows, selectedId, rowIndexById })
+ if (action) dispatch(action)
+ return action
+ },
+ [disabled, rows, selectedId, rowIndexById, dispatch]
+ )
+
+ const onKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (disabled || !NAVIGATION_KEYS.has(event.key)) return
+ if (event.altKey || event.ctrlKey || event.metaKey) return
+ event.preventDefault()
+ press(event.key)
+ },
+ [disabled, press]
+ )
+
+ return { onKeyDown, press }
+}
diff --git a/packages/ui-patterns/src/Trace/hooks/useSpan.ts b/packages/ui-patterns/src/Trace/hooks/useSpan.ts
new file mode 100644
index 00000000000..a12277ef327
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/hooks/useSpan.ts
@@ -0,0 +1,11 @@
+import { createContext, use } from 'react'
+
+import type { SpanContextValue } from '../types'
+
+export const SpanContext = createContext(null)
+
+export function useSpan(): SpanContextValue {
+ const value = use(SpanContext)
+ if (!value) throw new Error('This part must be rendered inside the Trace.Rows template')
+ return value
+}
diff --git a/packages/ui-patterns/src/Trace/hooks/useTimeScale.ts b/packages/ui-patterns/src/Trace/hooks/useTimeScale.ts
new file mode 100644
index 00000000000..1346a28aec5
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/hooks/useTimeScale.ts
@@ -0,0 +1,48 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+
+import { useView } from '../Trace'
+import type { TimeScale } from '../types'
+import { computeTimeScale } from '../utils'
+
+export interface UseTimeScaleOptions {
+ targetTickSpacing?: number
+}
+
+export interface UseTimeScaleResult {
+ ref: (element: T | null) => void
+ width: number
+ scale: TimeScale
+}
+
+export function useTimeScale(
+ options: UseTimeScaleOptions = {}
+): UseTimeScaleResult {
+ const { targetTickSpacing = 80 } = options
+ const { window, bounds } = useView()
+ const [element, setElement] = useState(null)
+ const [width, setWidth] = useState(0)
+
+ const ref = useCallback((node: T | null) => setElement(node), [])
+
+ useEffect(() => {
+ if (!element) return
+ const measure = () => {
+ const next = element.getBoundingClientRect().width
+ setWidth((previous) => (Math.abs(previous - next) < 0.5 ? previous : next))
+ }
+ measure()
+ if (typeof ResizeObserver === 'undefined') return
+ const observer = new ResizeObserver(measure)
+ observer.observe(element)
+ return () => observer.disconnect()
+ }, [element])
+
+ const [start, end] = window
+ const origin = bounds[0]
+ const scale = useMemo(
+ () => computeTimeScale({ window: [start, end], width, origin, targetTickSpacing }),
+ [start, end, width, origin, targetTickSpacing]
+ )
+
+ return { ref, width, scale }
+}
diff --git a/packages/ui-patterns/src/Trace/hooks/useVisibleRows.ts b/packages/ui-patterns/src/Trace/hooks/useVisibleRows.ts
new file mode 100644
index 00000000000..27bb51757ff
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/hooks/useVisibleRows.ts
@@ -0,0 +1,27 @@
+import { useMemo } from 'react'
+
+import { useData, useView } from '../Trace'
+import type { VisibleRow } from '../types'
+import { flattenTree, indexRowsById } from '../utils'
+
+export interface UseVisibleRowsResult {
+ rows: readonly VisibleRow[]
+ rowIndexById: ReadonlyMap
+ totalCount: number
+ matchCount: number | null
+}
+
+export function useVisibleRows(): UseVisibleRowsResult {
+ const index = useData()
+ const { collapsed, matchIds } = useView()
+
+ return useMemo(() => {
+ const rows = flattenTree(index, collapsed, matchIds)
+ return {
+ rows,
+ rowIndexById: indexRowsById(rows),
+ totalCount: index.byId.size,
+ matchCount: matchIds ? matchIds.size : null,
+ }
+ }, [index, collapsed, matchIds])
+}
diff --git a/packages/ui-patterns/src/Trace/index.ts b/packages/ui-patterns/src/Trace/index.ts
new file mode 100644
index 00000000000..ebd95365459
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/index.ts
@@ -0,0 +1,49 @@
+import { Bar, Duration, Markers } from './Bar'
+import { Cell } from './Cell'
+import { CollapseAll, Filter, Header, ResetZoom, Status, Title } from './Header'
+import { Inspector } from './Inspector'
+import { Minimap } from './Minimap'
+import { Root } from './Trace'
+import { Lane, Rows, Ruler, Waterfall } from './Waterfall'
+
+export const Trace = {
+ Root,
+ Header,
+ Title,
+ Status,
+ Filter,
+ CollapseAll,
+ ResetZoom,
+ Minimap,
+ Waterfall,
+ Ruler,
+ Rows,
+ Cell,
+ Lane,
+ Bar,
+ Markers,
+ Duration,
+ Inspector,
+}
+
+export { useSpan } from './hooks/useSpan'
+export { useTimeScale } from './hooks/useTimeScale'
+
+export type {
+ BarState,
+ MarkerKind,
+ RootProps,
+ Span,
+ SpanContextValue,
+ SpanEvent,
+ SpanKind,
+ SpanLink,
+ SpanStatus,
+ TimeScale,
+ TimeTick,
+ TimeWindow,
+ TraceData,
+ ViewAction,
+ ViewContextValue,
+ VisibleRow,
+} from './types'
diff --git a/packages/ui-patterns/src/Trace/types.ts b/packages/ui-patterns/src/Trace/types.ts
new file mode 100644
index 00000000000..4417319137d
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/types.ts
@@ -0,0 +1,189 @@
+import type { Dispatch, ReactNode } from 'react'
+
+export type SpanStatus = 'ok' | 'error' | 'unset'
+
+export type SpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer' | (string & {})
+
+export interface SpanEvent {
+ timeMs: number
+ name: string
+ attributes?: Record
+}
+
+export interface SpanLink {
+ traceId: string
+ spanId: string
+}
+
+export interface Span {
+ id: string
+ parentId: string | null
+ traceId: string
+ name: string
+ serviceName: string
+ kind: SpanKind
+ startMs: number
+ endMs: number | null
+ status: SpanStatus
+ attributes: Record
+ events: SpanEvent[]
+ links: SpanLink[]
+}
+
+export interface TraceData {
+ traceId?: string
+ spans: readonly Span[]
+ nowMs?: number
+}
+
+export type TimeWindow = readonly [start: number, end: number]
+
+export interface TraceIndex {
+ spans: readonly Span[]
+ byId: ReadonlyMap
+ childrenOf: ReadonlyMap
+ roots: readonly string[]
+ orphanIds: ReadonlySet
+ depthOf: ReadonlyMap
+ descendantCountOf: ReadonlyMap
+ bounds: TimeWindow
+ nowMs: number
+ serviceNames: readonly string[]
+}
+
+export type FilterMatch = 'inactive' | 'match' | 'ancestor'
+
+export interface VisibleRow {
+ id: string
+ span: Span
+ index: number
+ depth: number
+ parentId: string | null
+ hasChildren: boolean
+ isExpanded: boolean
+ hiddenDescendantCount: number
+ isOrphan: boolean
+ siblingIndex: number
+ siblingCount: number
+ guides: readonly boolean[]
+ filterMatch: FilterMatch
+}
+
+export interface TimeTick {
+ ms: number
+ fraction: number
+ x: number
+ label: string
+}
+
+export interface TimeScale {
+ window: TimeWindow
+ origin: number
+ width: number
+ durationMs: number
+ pxPerMs: number
+ stepMs: number
+ ticks: readonly TimeTick[]
+ toFraction: (ms: number) => number
+ toX: (ms: number) => number
+ toMs: (x: number) => number
+ format: (ms: number) => string
+}
+
+export interface TimeScaleOptions {
+ window: TimeWindow
+ width: number
+ origin?: number
+ targetTickSpacing?: number
+}
+
+export type BarState = 'default' | 'hover' | 'selected' | 'dim' | 'running'
+
+export type BarClip = 'none' | 'start' | 'end' | 'both'
+
+export interface BarGeometry {
+ isVisible: boolean
+ isZeroDuration: boolean
+ clip: BarClip
+ leftFraction: number
+ widthFraction: number
+}
+
+export type MarkerKind = 'log' | 'exception' | 'milestone'
+
+export type ValueKind = 'string' | 'number' | 'boolean' | 'null' | 'id' | 'url' | 'error' | 'json'
+
+export type KeyboardAction =
+ | { type: 'select'; id: string }
+ | { type: 'expand'; id: string }
+ | { type: 'collapse'; id: string }
+
+export type BrushEdge = 'start' | 'end'
+
+export type BrushMode = 'idle' | 'create' | 'move' | 'resize-start' | 'resize-end'
+
+export interface BrushRect {
+ left: number
+ width: number
+}
+
+export interface FilterTerm {
+ key: string | null
+ value: string
+ negate: boolean
+}
+
+export interface ViewState {
+ window: TimeWindow | null
+ selectedId: string | null
+ hoveredId: string | null
+ collapsed: ReadonlySet
+ query: string
+}
+
+export type ViewAction =
+ | { type: 'setWindow'; window: TimeWindow | null }
+ | { type: 'select'; id: string | null }
+ | { type: 'hover'; id: string | null }
+ | { type: 'toggle'; id: string }
+ | { type: 'expand'; id: string }
+ | { type: 'collapse'; id: string }
+ | { type: 'collapseAll' }
+ | { type: 'expandAll' }
+ | { type: 'setCollapsed'; collapsed: ReadonlySet }
+ | { type: 'setQuery'; query: string }
+
+export type DataContextValue = TraceIndex
+
+export interface ViewContextValue {
+ window: TimeWindow
+ bounds: TimeWindow
+ isReset: boolean
+ selectedId: string | null
+ hoveredId: string | null
+ collapsed: ReadonlySet
+ query: string
+ matchIds: ReadonlySet | null
+ dispatch: Dispatch
+}
+
+export interface SpanContextValue {
+ span: Span
+ row: VisibleRow
+ isSelected: boolean
+ isHovered: boolean
+ isRunning: boolean
+ state: BarState
+ readOnly: boolean
+}
+
+export interface RootProps {
+ trace: TraceData
+ selectedId?: string | null
+ onSelectedIdChange?: (id: string | null) => void
+ window?: TimeWindow
+ onWindowChange?: (window: TimeWindow) => void
+ defaultCollapsed?: ReadonlySet
+ defaultQuery?: string
+ children?: ReactNode
+}
diff --git a/packages/ui-patterns/src/Trace/utils.test.ts b/packages/ui-patterns/src/Trace/utils.test.ts
new file mode 100644
index 00000000000..e5cf332beca
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/utils.test.ts
@@ -0,0 +1,428 @@
+import { describe, expect, it } from 'vitest'
+
+import type { Span, TimeWindow } from './types'
+import {
+ brushRect,
+ buildTraceIndex,
+ clampWindow,
+ collectAncestors,
+ computeBarGeometry,
+ computeMatchIds,
+ computeTicks,
+ computeTimeScale,
+ flattenTree,
+ formatMs,
+ indexRowsById,
+ isFullWindow,
+ matches,
+ matchTerms,
+ msToPx,
+ niceTickStep,
+ panWindow,
+ parseFilterQuery,
+ precisionForStep,
+ pxToMs,
+ resizeWindow,
+ resolveKeyboardAction,
+ windowFromDrag,
+ zoomWindow,
+} from './utils'
+
+function makeSpan(
+ id: string,
+ parentId: string | null,
+ startMs: number,
+ endMs: number | null,
+ overrides: Partial = {}
+): Span {
+ return {
+ id,
+ parentId,
+ traceId: 't',
+ name: id,
+ serviceName: 'svc',
+ kind: 'internal',
+ startMs,
+ endMs,
+ status: 'ok',
+ attributes: {},
+ events: [],
+ links: [],
+ ...overrides,
+ }
+}
+
+describe('niceTickStep', () => {
+ it('picks the smallest 1-2-5 step that fits the tick budget', () => {
+ expect(niceTickStep(1000, 10)).toBe(100)
+ expect(niceTickStep(1000, 25)).toBe(50)
+ expect(niceTickStep(412, 8)).toBe(100)
+ expect(niceTickStep(3, 6)).toBe(0.5)
+ })
+
+ it('switches to clock-friendly steps above one second', () => {
+ expect(niceTickStep(60_000, 5)).toBe(15_000)
+ expect(niceTickStep(10 * 60_000, 6)).toBe(2 * 60_000)
+ expect(niceTickStep(3 * 3_600_000, 4)).toBe(3_600_000)
+ })
+
+ it('extends beyond the table in whole-day multiples', () => {
+ const step = niceTickStep(10 * 24 * 3_600_000, 2)
+ expect(step % (24 * 3_600_000)).toBe(0)
+ })
+
+ it('returns 0 for degenerate input', () => {
+ expect(niceTickStep(0, 10)).toBe(0)
+ expect(niceTickStep(100, 0)).toBe(0)
+ })
+})
+
+describe('formatMs', () => {
+ it('formats sub-millisecond values in microseconds', () => {
+ expect(formatMs(0)).toBe('0ms')
+ expect(formatMs(0.25)).toBe('250µs')
+ expect(formatMs(0.0042)).toBe('4.2µs')
+ })
+
+ it('formats milliseconds with fewer decimals as the value grows', () => {
+ expect(formatMs(1.234)).toBe('1.23ms')
+ expect(formatMs(12.34)).toBe('12.3ms')
+ expect(formatMs(123.4)).toBe('123ms')
+ })
+
+ it('formats seconds, minutes, and hours', () => {
+ expect(formatMs(1500)).toBe('1.5s')
+ expect(formatMs(65_000)).toBe('1m 5s')
+ expect(formatMs(120_000)).toBe('2m')
+ expect(formatMs(3_600_000 + 30 * 60_000)).toBe('1h 30m')
+ })
+
+ it('respects explicit precision and negative values', () => {
+ expect(formatMs(50.04, { precision: 2 })).toBe('50.04ms')
+ expect(formatMs(-250)).toBe('-250ms')
+ })
+})
+
+describe('precisionForStep', () => {
+ it('derives label precision from the tick step', () => {
+ expect(precisionForStep(100)).toBe(0)
+ expect(precisionForStep(0.5)).toBe(1)
+ expect(precisionForStep(0.05)).toBe(2)
+ expect(precisionForStep(0.0005)).toBe(4)
+ })
+})
+
+describe('computeTicks and computeTimeScale', () => {
+ it('aligns ticks to the origin, not to the window start', () => {
+ const { ticks, stepMs } = computeTicks({
+ window: [1030, 1430],
+ width: 400,
+ origin: 1000,
+ targetTickSpacing: 80,
+ })
+ expect(stepMs).toBe(100)
+ expect(ticks.map((tick) => tick.ms)).toEqual([1100, 1200, 1300, 1400])
+ expect(ticks.map((tick) => tick.label)).toEqual(['100ms', '200ms', '300ms', '400ms'])
+ })
+
+ it('returns nothing without width or duration', () => {
+ expect(
+ computeTicks({ window: [0, 100], width: 0, origin: 0, targetTickSpacing: 80 }).ticks
+ ).toEqual([])
+ expect(
+ computeTicks({ window: [5, 5], width: 100, origin: 0, targetTickSpacing: 80 }).ticks
+ ).toEqual([])
+ })
+
+ it('maps ms to x and back', () => {
+ const scale = computeTimeScale({ window: [200, 700], width: 1000 })
+ expect(scale.toX(450)).toBe(500)
+ expect(scale.toFraction(700)).toBe(1)
+ expect(scale.toMs(250)).toBe(325)
+ expect(scale.pxPerMs).toBe(2)
+ })
+
+ it('handles an empty window without dividing by zero', () => {
+ const scale = computeTimeScale({ window: [100, 100], width: 300 })
+ expect(scale.toX(100)).toBe(0)
+ expect(scale.toMs(50)).toBe(100)
+ })
+})
+
+const treeSpans: Span[] = [
+ makeSpan('root', null, 0, 100),
+ makeSpan('b', 'root', 40, 60),
+ makeSpan('a', 'root', 10, 30),
+ makeSpan('a1', 'a', 12, 20),
+ makeSpan('a2', 'a', 8, 25),
+ makeSpan('b1', 'b', 45, 45),
+ makeSpan('orphan', 'ghost', 70, 80),
+ makeSpan('running', 'root', 90, null),
+]
+
+describe('buildTraceIndex', () => {
+ const index = buildTraceIndex(treeSpans)
+
+ it('sorts children by start time and treats orphans as roots', () => {
+ expect(index.roots).toEqual(['root', 'orphan'])
+ expect(index.childrenOf.get('root')).toEqual(['a', 'b', 'running'])
+ expect(index.childrenOf.get('a')).toEqual(['a2', 'a1'])
+ expect(index.orphanIds.has('orphan')).toBe(true)
+ })
+
+ it('computes depth and descendant counts', () => {
+ expect(index.depthOf.get('a1')).toBe(2)
+ expect(index.descendantCountOf.get('root')).toBe(6)
+ expect(index.descendantCountOf.get('a')).toBe(2)
+ })
+
+ it('derives bounds and extends them for running spans when nowMs is provided', () => {
+ expect(index.bounds).toEqual([0, 100])
+ expect(buildTraceIndex(treeSpans, 150).bounds).toEqual([0, 150])
+ expect(buildTraceIndex([]).bounds).toEqual([0, 0])
+ })
+})
+
+describe('flattenTree', () => {
+ const index = buildTraceIndex(treeSpans)
+
+ it('flattens depth-first in start order when nothing is collapsed', () => {
+ const rows = flattenTree(index, new Set(), null)
+ expect(rows.map((row) => row.id)).toEqual([
+ 'root',
+ 'a',
+ 'a2',
+ 'a1',
+ 'b',
+ 'b1',
+ 'running',
+ 'orphan',
+ ])
+ expect(rows.map((row) => row.depth)).toEqual([0, 1, 2, 2, 1, 2, 1, 0])
+ expect(rows.every((row) => row.filterMatch === 'inactive')).toBe(true)
+ })
+
+ it('hides descendants of collapsed nodes and reports how many are hidden', () => {
+ const rows = flattenTree(index, new Set(['a', 'root']), null)
+ expect(rows.map((row) => row.id)).toEqual(['root', 'orphan'])
+ expect(rows[0].hiddenDescendantCount).toBe(6)
+ const partial = flattenTree(index, new Set(['a']), null)
+ expect(partial.find((row) => row.id === 'a')?.hiddenDescendantCount).toBe(2)
+ })
+
+ it('computes sibling positions and depth guides', () => {
+ const rows = flattenTree(index, new Set(), null)
+ const a2 = rows.find((row) => row.id === 'a2')!
+ expect(a2.siblingIndex).toBe(0)
+ expect(a2.siblingCount).toBe(2)
+ expect(a2.guides).toEqual([true, true])
+ expect(rows.find((row) => row.id === 'running')!.guides).toEqual([true])
+ })
+
+ it('keeps matches and their ancestors when a filter is active', () => {
+ const rows = flattenTree(index, new Set(), new Set(['a1']))
+ expect(rows.map((row) => row.id)).toEqual(['root', 'a', 'a1'])
+ expect(rows.map((row) => row.filterMatch)).toEqual(['ancestor', 'ancestor', 'match'])
+ })
+
+ it('counts hidden descendants within the filtered set', () => {
+ const rows = flattenTree(index, new Set(['root']), new Set(['a1', 'b1']))
+ expect(rows.map((row) => row.id)).toEqual(['root'])
+ expect(rows[0].hiddenDescendantCount).toBe(4)
+ expect(flattenTree(index, new Set(), new Set())).toEqual([])
+ })
+
+ it('collects ancestors up to the root and stops at missing parents', () => {
+ const keep = collectAncestors(index, new Set(['a1', 'orphan']))
+ expect(Array.from(keep).sort()).toEqual(['a', 'a1', 'orphan', 'root'])
+ })
+})
+
+describe('filter', () => {
+ const span = makeSpan('span-1', null, 0, 10, {
+ name: 'SELECT orders',
+ serviceName: 'postgres',
+ kind: 'client',
+ status: 'error',
+ attributes: {
+ 'db.statement': 'SELECT * FROM orders',
+ 'http.status_code': 502,
+ 'cache.hit': false,
+ 'user.id': null,
+ },
+ events: [{ timeMs: 5, name: 'exception' }],
+ })
+
+ it('parses free text, key:value terms, quotes, and negation', () => {
+ expect(parseFilterQuery('orders status:error service=postgres')).toEqual([
+ { key: null, value: 'orders', negate: false },
+ { key: 'status', value: 'error', negate: false },
+ { key: 'service', value: 'postgres', negate: false },
+ ])
+ expect(parseFilterQuery('db.statement:"SELECT * FROM" -status:ok -cache')).toEqual([
+ { key: 'db.statement', value: 'SELECT * FROM', negate: false },
+ { key: 'status', value: 'ok', negate: true },
+ { key: null, value: 'cache', negate: true },
+ ])
+ expect(parseFilterQuery('-')).toEqual([{ key: null, value: '-', negate: false }])
+ })
+
+ it('matches free text against name, service, id, attributes, and events', () => {
+ expect(matches(span, 'select')).toBe(true)
+ expect(matches(span, 'POSTGRES')).toBe(true)
+ expect(matches(span, '502')).toBe(true)
+ expect(matches(span, 'exception')).toBe(true)
+ expect(matches(span, 'redis')).toBe(false)
+ })
+
+ it('matches keyed terms and honours negation', () => {
+ expect(matches(span, 'status:error')).toBe(true)
+ expect(matches(span, 'cache.hit:false')).toBe(true)
+ expect(matches(span, 'user.id:null')).toBe(true)
+ expect(matches(span, 'missing.key:anything')).toBe(false)
+ expect(matches(span, 'select status:ok')).toBe(false)
+ expect(matchTerms(span, parseFilterQuery('-select'))).toBe(false)
+ })
+
+ it('computes match ids and treats an empty query as inactive', () => {
+ const other = makeSpan('span-2', null, 0, 5, { name: 'cache.get', serviceName: 'redis' })
+ const index = buildTraceIndex([span, other])
+ expect(computeMatchIds(index, ' ')).toBeNull()
+ expect(Array.from(computeMatchIds(index, 'status:error')!)).toEqual(['span-1'])
+ expect(computeMatchIds(index, 'nothing-here')!.size).toBe(0)
+ })
+})
+
+describe('brush math', () => {
+ const bounds: TimeWindow = [1000, 2000]
+
+ it('converts between px and ms', () => {
+ expect(msToPx(1500, bounds, 500)).toBe(250)
+ expect(pxToMs(125, bounds, 500)).toBe(1250)
+ expect(pxToMs(100, bounds, 0)).toBe(1000)
+ })
+
+ it('clamps windows inside bounds, preserving size and minimum duration', () => {
+ expect(clampWindow([900, 1200], bounds)).toEqual([1000, 1300])
+ expect(clampWindow([1800, 2100], bounds)).toEqual([1700, 2000])
+ expect(clampWindow([500, 2500], bounds)).toEqual([1000, 2000])
+ expect(clampWindow([1500, 1500], bounds, 100)).toEqual([1450, 1550])
+ expect(clampWindow([1600, 1200], bounds)).toEqual([1200, 1600])
+ })
+
+ it('pans without resizing and stops at the edges', () => {
+ expect(panWindow([1200, 1400], 100, bounds)).toEqual([1300, 1500])
+ expect(panWindow([1200, 1400], 5000, bounds)).toEqual([1800, 2000])
+ })
+
+ it('resizes one edge and respects the minimum duration', () => {
+ expect(resizeWindow([1200, 1600], 'start', 1300, bounds)).toEqual([1300, 1600])
+ expect(resizeWindow([1200, 1600], 'start', 1700, bounds, 50)).toEqual([1550, 1600])
+ expect(resizeWindow([1200, 1600], 'end', 9999, bounds)).toEqual([1200, 2000])
+ })
+
+ it('builds windows from drags and zooms around an anchor', () => {
+ expect(windowFromDrag(1600, 1200, bounds)).toEqual([1200, 1600])
+ expect(zoomWindow([1200, 1600], 0.5, 1400, bounds)).toEqual([1300, 1500])
+ expect(zoomWindow([1200, 1600], 10, undefined, bounds)).toEqual([1000, 2000])
+ })
+
+ it('converts a window to a pixel rectangle and detects the full window', () => {
+ expect(brushRect([1250, 1750], bounds, 400)).toEqual({ left: 100, width: 200 })
+ expect(isFullWindow([1000, 2000], bounds)).toBe(true)
+ expect(isFullWindow([1000, 1999], bounds)).toBe(false)
+ })
+})
+
+describe('resolveKeyboardAction', () => {
+ const index = buildTraceIndex([
+ makeSpan('root', null, 0, 10),
+ makeSpan('a', 'root', 1, 11),
+ makeSpan('a1', 'a', 2, 12),
+ makeSpan('b', 'root', 5, 15),
+ ])
+ const setup = (collapsed: string[] = []) => {
+ const rows = flattenTree(index, new Set(collapsed), null)
+ return { rows, rowIndexById: indexRowsById(rows) }
+ }
+
+ it('moves up and down and stops at the edges', () => {
+ const tree = setup()
+ expect(resolveKeyboardAction({ key: 'ArrowDown', ...tree, selectedId: null })).toEqual({
+ type: 'select',
+ id: 'root',
+ })
+ expect(resolveKeyboardAction({ key: 'ArrowDown', ...tree, selectedId: 'a' })).toEqual({
+ type: 'select',
+ id: 'a1',
+ })
+ expect(resolveKeyboardAction({ key: 'ArrowUp', ...tree, selectedId: 'root' })).toBeNull()
+ expect(resolveKeyboardAction({ key: 'ArrowDown', ...tree, selectedId: 'b' })).toBeNull()
+ })
+
+ it('expands on ArrowRight, then moves into the node', () => {
+ expect(resolveKeyboardAction({ key: 'ArrowRight', ...setup(['a']), selectedId: 'a' })).toEqual({
+ type: 'expand',
+ id: 'a',
+ })
+ expect(resolveKeyboardAction({ key: 'ArrowRight', ...setup(), selectedId: 'a' })).toEqual({
+ type: 'select',
+ id: 'a1',
+ })
+ expect(resolveKeyboardAction({ key: 'ArrowRight', ...setup(), selectedId: 'b' })).toBeNull()
+ })
+
+ it('collapses on ArrowLeft, otherwise jumps to the parent', () => {
+ const tree = setup()
+ expect(resolveKeyboardAction({ key: 'ArrowLeft', ...tree, selectedId: 'a' })).toEqual({
+ type: 'collapse',
+ id: 'a',
+ })
+ expect(resolveKeyboardAction({ key: 'ArrowLeft', ...tree, selectedId: 'a1' })).toEqual({
+ type: 'select',
+ id: 'a',
+ })
+ expect(resolveKeyboardAction({ key: 'ArrowLeft', ...tree, selectedId: 'b' })).toEqual({
+ type: 'select',
+ id: 'root',
+ })
+ })
+
+ it('jumps with Home and End and ignores other keys', () => {
+ const tree = setup()
+ expect(resolveKeyboardAction({ key: 'Home', ...tree, selectedId: 'b' })).toEqual({
+ type: 'select',
+ id: 'root',
+ })
+ expect(resolveKeyboardAction({ key: 'End', ...tree, selectedId: 'root' })).toEqual({
+ type: 'select',
+ id: 'b',
+ })
+ expect(resolveKeyboardAction({ key: 'Enter', ...tree, selectedId: 'a' })).toBeNull()
+ })
+})
+
+describe('computeBarGeometry', () => {
+ const window: TimeWindow = [100, 200]
+
+ it('positions and clips bars against the window', () => {
+ expect(computeBarGeometry(125, 175, window)).toMatchObject({
+ clip: 'none',
+ leftFraction: 0.25,
+ widthFraction: 0.5,
+ })
+ expect(computeBarGeometry(50, 150, window)).toMatchObject({ clip: 'start', leftFraction: 0 })
+ expect(computeBarGeometry(150, 250, window)).toMatchObject({ clip: 'end', widthFraction: 0.5 })
+ expect(computeBarGeometry(0, 300, window)).toMatchObject({ clip: 'both', widthFraction: 1 })
+ })
+
+ it('hides bars outside the window and flags zero duration', () => {
+ expect(computeBarGeometry(0, 50, window).isVisible).toBe(false)
+ expect(computeBarGeometry(150, 150, window)).toMatchObject({
+ isVisible: true,
+ isZeroDuration: true,
+ })
+ expect(computeBarGeometry(150, 140, window).isZeroDuration).toBe(true)
+ expect(computeBarGeometry(100, 150, [100, 100]).isVisible).toBe(false)
+ })
+})
diff --git a/packages/ui-patterns/src/Trace/utils.ts b/packages/ui-patterns/src/Trace/utils.ts
new file mode 100644
index 00000000000..b67a3c205a6
--- /dev/null
+++ b/packages/ui-patterns/src/Trace/utils.ts
@@ -0,0 +1,780 @@
+import type {
+ BarGeometry,
+ BrushEdge,
+ BrushRect,
+ FilterMatch,
+ FilterTerm,
+ KeyboardAction,
+ MarkerKind,
+ Span,
+ SpanEvent,
+ TimeScale,
+ TimeScaleOptions,
+ TimeTick,
+ TimeWindow,
+ TraceIndex,
+ ValueKind,
+ VisibleRow,
+} from './types'
+
+// Trace index
+
+export function effectiveEndMs(span: Pick, nowMs: number): number {
+ if (span.endMs === null) return Math.max(span.startMs, nowMs)
+ return span.endMs
+}
+
+export function durationOf(span: Pick, nowMs: number): number {
+ return Math.max(0, effectiveEndMs(span, nowMs) - span.startMs)
+}
+
+function compareByStart(byId: ReadonlyMap) {
+ return (a: string, b: string) => {
+ const sa = byId.get(a)!
+ const sb = byId.get(b)!
+ if (sa.startMs !== sb.startMs) return sa.startMs - sb.startMs
+ return sa.id < sb.id ? -1 : sa.id > sb.id ? 1 : 0
+ }
+}
+
+export function buildTraceIndex(spans: readonly Span[], nowMs?: number): TraceIndex {
+ const byId = new Map()
+ for (const span of spans) byId.set(span.id, span)
+
+ const childrenOf = new Map()
+ const roots: string[] = []
+ const orphanIds = new Set()
+ const services = new Set()
+
+ let minStart = Number.POSITIVE_INFINITY
+ let maxKnown = Number.NEGATIVE_INFINITY
+
+ for (const span of byId.values()) {
+ services.add(span.serviceName)
+ if (span.startMs < minStart) minStart = span.startMs
+ if (span.startMs > maxKnown) maxKnown = span.startMs
+ if (span.endMs !== null && span.endMs > maxKnown) maxKnown = span.endMs
+
+ const hasParent = span.parentId !== null && byId.has(span.parentId)
+ if (span.parentId !== null && !hasParent) orphanIds.add(span.id)
+ if (!hasParent) {
+ roots.push(span.id)
+ continue
+ }
+ const siblings = childrenOf.get(span.parentId!)
+ if (siblings) siblings.push(span.id)
+ else childrenOf.set(span.parentId!, [span.id])
+ }
+
+ const compare = compareByStart(byId)
+ roots.sort(compare)
+ for (const ids of childrenOf.values()) ids.sort(compare)
+
+ const resolvedNow = nowMs ?? (Number.isFinite(maxKnown) ? maxKnown : 0)
+
+ let maxEnd = maxKnown
+ for (const span of byId.values()) {
+ const end = effectiveEndMs(span, resolvedNow)
+ if (end > maxEnd) maxEnd = end
+ }
+
+ const depthOf = new Map()
+ const descendantCountOf = new Map()
+ const postOrder: string[] = []
+ const stack: Array<{ id: string; depth: number }> = []
+ for (let i = roots.length - 1; i >= 0; i--) stack.push({ id: roots[i], depth: 0 })
+
+ while (stack.length > 0) {
+ const { id, depth } = stack.pop()!
+ if (depthOf.has(id)) continue
+ depthOf.set(id, depth)
+ postOrder.push(id)
+ const children = childrenOf.get(id)
+ if (!children) continue
+ for (let i = children.length - 1; i >= 0; i--) stack.push({ id: children[i], depth: depth + 1 })
+ }
+
+ for (let i = postOrder.length - 1; i >= 0; i--) {
+ const id = postOrder[i]
+ const children = childrenOf.get(id)
+ let count = 0
+ if (children) {
+ for (const child of children) count += 1 + (descendantCountOf.get(child) ?? 0)
+ }
+ descendantCountOf.set(id, count)
+ }
+
+ const bounds: TimeWindow = Number.isFinite(minStart)
+ ? [minStart, Math.max(maxEnd, minStart)]
+ : [0, 0]
+
+ return {
+ spans,
+ byId,
+ childrenOf,
+ roots,
+ orphanIds,
+ depthOf,
+ descendantCountOf,
+ bounds,
+ nowMs: resolvedNow,
+ serviceNames: Array.from(services).sort(),
+ }
+}
+
+export const EMPTY_TRACE_INDEX: TraceIndex = buildTraceIndex([])
+
+// Tree flattening
+
+export function collectAncestors(
+ index: TraceIndex,
+ matchIds: ReadonlySet
+): ReadonlySet {
+ const keep = new Set(matchIds)
+ for (const id of matchIds) {
+ let current = index.byId.get(id)
+ while (current && current.parentId !== null) {
+ const parent = index.byId.get(current.parentId)
+ if (!parent || keep.has(parent.id)) break
+ keep.add(parent.id)
+ current = parent
+ }
+ }
+ return keep
+}
+
+interface Frame {
+ id: string
+ depth: number
+ siblingIndex: number
+ siblingCount: number
+ guides: boolean[]
+}
+
+export function flattenTree(
+ index: TraceIndex,
+ collapsedIds: ReadonlySet,
+ matchIds: ReadonlySet | null
+): VisibleRow[] {
+ const keep = matchIds ? collectAncestors(index, matchIds) : null
+ const rows: VisibleRow[] = []
+
+ const visibleChildren = (id: string): string[] => {
+ const children = index.childrenOf.get(id)
+ if (!children) return []
+ if (!keep) return children as string[]
+ return children.filter((child) => keep.has(child))
+ }
+
+ const countVisibleDescendants = (id: string): number => {
+ if (!keep) return index.descendantCountOf.get(id) ?? 0
+ let count = 0
+ const stack = [...visibleChildren(id)]
+ while (stack.length > 0) {
+ const next = stack.pop()!
+ count++
+ for (const child of visibleChildren(next)) stack.push(child)
+ }
+ return count
+ }
+
+ const topLevel = keep ? index.roots.filter((id) => keep.has(id)) : index.roots
+ const stack: Frame[] = []
+ for (let i = topLevel.length - 1; i >= 0; i--) {
+ stack.push({
+ id: topLevel[i],
+ depth: 0,
+ siblingIndex: i,
+ siblingCount: topLevel.length,
+ guides: [],
+ })
+ }
+
+ while (stack.length > 0) {
+ const frame = stack.pop()!
+ const span = index.byId.get(frame.id)
+ if (!span) continue
+
+ const children = visibleChildren(frame.id)
+ const hasChildren = children.length > 0
+ const isCollapsed = collapsedIds.has(frame.id)
+ const isExpanded = hasChildren && !isCollapsed
+
+ let filterMatch: FilterMatch = 'inactive'
+ if (matchIds) filterMatch = matchIds.has(frame.id) ? 'match' : 'ancestor'
+
+ rows.push({
+ id: frame.id,
+ span,
+ index: rows.length,
+ depth: frame.depth,
+ parentId: span.parentId !== null && index.byId.has(span.parentId) ? span.parentId : null,
+ hasChildren,
+ isExpanded,
+ hiddenDescendantCount: hasChildren && isCollapsed ? countVisibleDescendants(frame.id) : 0,
+ isOrphan: index.orphanIds.has(frame.id),
+ siblingIndex: frame.siblingIndex,
+ siblingCount: frame.siblingCount,
+ guides: frame.guides,
+ filterMatch,
+ })
+
+ if (!isExpanded) continue
+
+ const hasFollowingSibling = frame.siblingIndex < frame.siblingCount - 1
+ const childGuides = [...frame.guides, hasFollowingSibling]
+ for (let i = children.length - 1; i >= 0; i--) {
+ stack.push({
+ id: children[i],
+ depth: frame.depth + 1,
+ siblingIndex: i,
+ siblingCount: children.length,
+ guides: childGuides,
+ })
+ }
+ }
+
+ return rows
+}
+
+export function indexRowsById(rows: readonly VisibleRow[]): ReadonlyMap {
+ const map = new Map()
+ for (const row of rows) map.set(row.id, row.index)
+ return map
+}
+
+// Time scale
+
+const SECOND = 1000
+const MINUTE = 60 * SECOND
+const HOUR = 60 * MINUTE
+
+const SUB_SECOND_STEPS = [
+ 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500,
+]
+
+const CLOCK_STEPS = [
+ SECOND,
+ 2 * SECOND,
+ 5 * SECOND,
+ 10 * SECOND,
+ 15 * SECOND,
+ 30 * SECOND,
+ MINUTE,
+ 2 * MINUTE,
+ 5 * MINUTE,
+ 10 * MINUTE,
+ 15 * MINUTE,
+ 30 * MINUTE,
+ HOUR,
+ 2 * HOUR,
+ 3 * HOUR,
+ 6 * HOUR,
+ 12 * HOUR,
+ 24 * HOUR,
+]
+
+export const NICE_STEPS_MS: readonly number[] = [...SUB_SECOND_STEPS, ...CLOCK_STEPS]
+
+export function niceTickStep(durationMs: number, maxTicks: number): number {
+ if (!(durationMs > 0) || !(maxTicks > 0)) return 0
+ const raw = durationMs / maxTicks
+ for (const step of NICE_STEPS_MS) {
+ if (step >= raw) return step
+ }
+ const last = NICE_STEPS_MS[NICE_STEPS_MS.length - 1]
+ return Math.ceil(raw / last) * last
+}
+
+function trimZeros(value: string): string {
+ return value.includes('.') ? value.replace(/\.?0+$/, '') : value
+}
+
+export interface FormatMsOptions {
+ precision?: number
+}
+
+export function formatMs(ms: number, options: FormatMsOptions = {}): string {
+ if (!Number.isFinite(ms)) return '–'
+ const sign = ms < 0 ? '-' : ''
+ const abs = Math.abs(ms)
+ const { precision } = options
+
+ if (abs === 0) return '0ms'
+ if (abs < 1) {
+ const micro = abs * 1000
+ const digits = precision ?? (micro < 10 ? 1 : 0)
+ return `${sign}${trimZeros(micro.toFixed(digits))}µs`
+ }
+ if (abs < SECOND) {
+ const digits = precision ?? (abs < 10 ? 2 : abs < 100 ? 1 : 0)
+ return `${sign}${trimZeros(abs.toFixed(digits))}ms`
+ }
+ if (abs < MINUTE) {
+ const digits = precision ?? 2
+ return `${sign}${trimZeros((abs / SECOND).toFixed(digits))}s`
+ }
+ if (abs < HOUR) {
+ const minutes = Math.floor(abs / MINUTE)
+ const seconds = Math.round((abs - minutes * MINUTE) / SECOND)
+ return seconds === 0 ? `${sign}${minutes}m` : `${sign}${minutes}m ${seconds}s`
+ }
+ const hours = Math.floor(abs / HOUR)
+ const minutes = Math.round((abs - hours * HOUR) / MINUTE)
+ return minutes === 0 ? `${sign}${hours}h` : `${sign}${hours}h ${minutes}m`
+}
+
+export function precisionForStep(stepMs: number): number {
+ if (stepMs <= 0) return 0
+ if (stepMs < SECOND) return Math.max(0, -Math.floor(Math.log10(stepMs)))
+ if (stepMs < MINUTE) return Math.max(0, -Math.floor(Math.log10(stepMs / SECOND)))
+ return 0
+}
+
+export function computeTicks(options: Required): {
+ ticks: TimeTick[]
+ stepMs: number
+} {
+ const { window, width, origin, targetTickSpacing } = options
+ const [start, end] = window
+ const durationMs = end - start
+ if (!(durationMs > 0) || !(width > 0)) return { ticks: [], stepMs: 0 }
+
+ const maxTicks = Math.max(1, Math.floor(width / targetTickSpacing))
+ const stepMs = niceTickStep(durationMs, maxTicks)
+ if (stepMs <= 0) return { ticks: [], stepMs: 0 }
+
+ const precision = precisionForStep(stepMs)
+ const ticks: TimeTick[] = []
+ const firstOffset = Math.ceil((start - origin) / stepMs - 1e-9) * stepMs
+ const guard = maxTicks + 2
+
+ for (let offset = firstOffset, i = 0; i < guard; offset += stepMs, i++) {
+ const ms = origin + offset
+ if (ms > end + 1e-9) break
+ if (ms < start - 1e-9) continue
+ const fraction = (ms - start) / durationMs
+ ticks.push({
+ ms,
+ fraction,
+ x: fraction * width,
+ label: formatMs(ms - origin, { precision }),
+ })
+ }
+
+ return { ticks, stepMs }
+}
+
+export function computeTimeScale(options: TimeScaleOptions): TimeScale {
+ const window = options.window
+ const width = Math.max(0, options.width)
+ const origin = options.origin ?? window[0]
+ const targetTickSpacing = options.targetTickSpacing ?? 80
+ const durationMs = window[1] - window[0]
+ const pxPerMs = durationMs > 0 ? width / durationMs : 0
+
+ const { ticks, stepMs } = computeTicks({ window, width, origin, targetTickSpacing })
+
+ return {
+ window,
+ origin,
+ width,
+ durationMs,
+ pxPerMs,
+ stepMs,
+ ticks,
+ toFraction: (ms) => (durationMs > 0 ? (ms - window[0]) / durationMs : 0),
+ toX: (ms) => (durationMs > 0 ? ((ms - window[0]) / durationMs) * width : 0),
+ toMs: (x) => (width > 0 ? window[0] + (x / width) * durationMs : window[0]),
+ format: (ms) => formatMs(ms),
+ }
+}
+
+// Filter
+
+export function parseFilterQuery(query: string): FilterTerm[] {
+ const terms: FilterTerm[] = []
+ const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []
+ for (const raw of tokens) {
+ let token = raw
+ let negate = false
+ if (token.startsWith('-') && token.length > 1) {
+ negate = true
+ token = token.slice(1)
+ }
+ const separator = token.search(/[:=]/)
+ if (separator > 0) {
+ const key = token.slice(0, separator)
+ const value = stripQuotes(token.slice(separator + 1))
+ terms.push({ key, value, negate })
+ } else {
+ terms.push({ key: null, value: stripQuotes(token), negate })
+ }
+ }
+ return terms
+}
+
+function stripQuotes(value: string): string {
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1)
+ return value
+}
+
+function includesInsensitive(haystack: string, needle: string): boolean {
+ return haystack.toLowerCase().includes(needle)
+}
+
+function fieldValue(span: Span, key: string): unknown {
+ switch (key) {
+ case 'name':
+ return span.name
+ case 'service':
+ case 'serviceName':
+ return span.serviceName
+ case 'status':
+ return span.status
+ case 'kind':
+ return span.kind
+ case 'id':
+ return span.id
+ default:
+ return span.attributes[key]
+ }
+}
+
+function matchTerm(span: Span, term: FilterTerm): boolean {
+ const needle = term.value.toLowerCase()
+ if (term.key !== null) {
+ const value = fieldValue(span, term.key)
+ if (value === undefined || value === null) return needle === 'null' && value === null
+ return needle === '' ? true : includesInsensitive(String(value), needle)
+ }
+ if (needle === '') return true
+ if (includesInsensitive(span.name, needle)) return true
+ if (includesInsensitive(span.serviceName, needle)) return true
+ if (includesInsensitive(span.id, needle)) return true
+ for (const value of Object.values(span.attributes)) {
+ if (typeof value === 'string' && includesInsensitive(value, needle)) return true
+ if (typeof value === 'number' && String(value).includes(needle)) return true
+ }
+ for (const event of span.events) {
+ if (includesInsensitive(event.name, needle)) return true
+ }
+ return false
+}
+
+export function matchTerms(span: Span, terms: readonly FilterTerm[]): boolean {
+ for (const term of terms) {
+ const matched = matchTerm(span, term)
+ if (term.negate ? matched : !matched) return false
+ }
+ return true
+}
+
+export function matches(span: Span, query: string): boolean {
+ const terms = parseFilterQuery(query)
+ if (terms.length === 0) return true
+ return matchTerms(span, terms)
+}
+
+export function computeMatchIds(index: TraceIndex, query: string): ReadonlySet | null {
+ if (query.trim() === '') return null
+ const result = new Set()
+ for (const span of index.byId.values()) {
+ if (matches(span, query)) result.add(span.id)
+ }
+ return result
+}
+
+// Brush
+
+export function boundsDuration(bounds: TimeWindow): number {
+ return Math.max(0, bounds[1] - bounds[0])
+}
+
+export function msToFraction(ms: number, bounds: TimeWindow): number {
+ const duration = boundsDuration(bounds)
+ if (duration === 0) return 0
+ return (ms - bounds[0]) / duration
+}
+
+export function fractionToMs(fraction: number, bounds: TimeWindow): number {
+ return bounds[0] + fraction * boundsDuration(bounds)
+}
+
+export function msToPx(ms: number, bounds: TimeWindow, width: number): number {
+ return msToFraction(ms, bounds) * width
+}
+
+export function pxToMs(px: number, bounds: TimeWindow, width: number): number {
+ if (width <= 0) return bounds[0]
+ return fractionToMs(px / width, bounds)
+}
+
+export function resolveMinDuration(bounds: TimeWindow, minDurationMs?: number): number {
+ const duration = boundsDuration(bounds)
+ if (minDurationMs !== undefined) return Math.min(minDurationMs, duration)
+ return Math.min(duration, Math.max(duration / 1000, 0.001))
+}
+
+export function clampWindow(
+ window: TimeWindow,
+ bounds: TimeWindow,
+ minDurationMs?: number
+): TimeWindow {
+ const minDuration = resolveMinDuration(bounds, minDurationMs)
+ const total = boundsDuration(bounds)
+ let [start, end] = window[0] <= window[1] ? window : [window[1], window[0]]
+
+ if (end - start < minDuration) {
+ const center = (start + end) / 2
+ start = center - minDuration / 2
+ end = center + minDuration / 2
+ }
+ if (end - start >= total) return [bounds[0], bounds[1]]
+
+ if (start < bounds[0]) {
+ end += bounds[0] - start
+ start = bounds[0]
+ }
+ if (end > bounds[1]) {
+ start -= end - bounds[1]
+ end = bounds[1]
+ }
+ return [Math.max(start, bounds[0]), Math.min(end, bounds[1])]
+}
+
+export function panWindow(window: TimeWindow, deltaMs: number, bounds: TimeWindow): TimeWindow {
+ const size = window[1] - window[0]
+ let start = window[0] + deltaMs
+ if (start < bounds[0]) start = bounds[0]
+ if (start + size > bounds[1]) start = bounds[1] - size
+ return [start, start + size]
+}
+
+export function resizeWindow(
+ window: TimeWindow,
+ edge: BrushEdge,
+ ms: number,
+ bounds: TimeWindow,
+ minDurationMs?: number
+): TimeWindow {
+ const minDuration = resolveMinDuration(bounds, minDurationMs)
+ const clampedMs = Math.min(Math.max(ms, bounds[0]), bounds[1])
+ if (edge === 'start') {
+ const start = Math.min(clampedMs, window[1] - minDuration)
+ return [Math.max(start, bounds[0]), window[1]]
+ }
+ const end = Math.max(clampedMs, window[0] + minDuration)
+ return [window[0], Math.min(end, bounds[1])]
+}
+
+export function windowFromDrag(
+ aMs: number,
+ bMs: number,
+ bounds: TimeWindow,
+ minDurationMs?: number
+): TimeWindow {
+ const start = Math.min(aMs, bMs)
+ const end = Math.max(aMs, bMs)
+ return clampWindow([start, end], bounds, minDurationMs)
+}
+
+export function zoomWindow(
+ window: TimeWindow,
+ factor: number,
+ anchorMs: number | undefined,
+ bounds: TimeWindow,
+ minDurationMs?: number
+): TimeWindow {
+ if (!(factor > 0)) return window
+ const size = window[1] - window[0]
+ const anchor = anchorMs ?? (window[0] + window[1]) / 2
+ const ratio = size > 0 ? (anchor - window[0]) / size : 0.5
+ const nextSize = size * factor
+ const start = anchor - ratio * nextSize
+ return clampWindow([start, start + nextSize], bounds, minDurationMs)
+}
+
+export function brushRect(window: TimeWindow, bounds: TimeWindow, width: number): BrushRect {
+ const left = msToPx(window[0], bounds, width)
+ const right = msToPx(window[1], bounds, width)
+ return { left, width: Math.max(0, right - left) }
+}
+
+export function isFullWindow(window: TimeWindow, bounds: TimeWindow): boolean {
+ return window[0] <= bounds[0] && window[1] >= bounds[1]
+}
+
+// Keyboard
+
+export interface ResolveKeyboardActionInput {
+ key: string
+ rows: readonly VisibleRow[]
+ selectedId: string | null
+ rowIndexById: ReadonlyMap
+}
+
+export function resolveKeyboardAction(input: ResolveKeyboardActionInput): KeyboardAction | null {
+ const { key, rows, selectedId, rowIndexById } = input
+ if (rows.length === 0) return null
+
+ const currentIndex = selectedId !== null ? (rowIndexById.get(selectedId) ?? -1) : -1
+ const current = currentIndex >= 0 ? rows[currentIndex] : null
+
+ switch (key) {
+ case 'ArrowDown': {
+ if (!current) return { type: 'select', id: rows[0].id }
+ const next = rows[Math.min(currentIndex + 1, rows.length - 1)]
+ return next.id === current.id ? null : { type: 'select', id: next.id }
+ }
+ case 'ArrowUp': {
+ if (!current) return { type: 'select', id: rows[rows.length - 1].id }
+ const prev = rows[Math.max(currentIndex - 1, 0)]
+ return prev.id === current.id ? null : { type: 'select', id: prev.id }
+ }
+ case 'Home':
+ return rows[0].id === selectedId ? null : { type: 'select', id: rows[0].id }
+ case 'End': {
+ const last = rows[rows.length - 1]
+ return last.id === selectedId ? null : { type: 'select', id: last.id }
+ }
+ case 'ArrowRight': {
+ if (!current) return { type: 'select', id: rows[0].id }
+ if (current.hasChildren && !current.isExpanded) return { type: 'expand', id: current.id }
+ if (current.isExpanded && currentIndex + 1 < rows.length) {
+ return { type: 'select', id: rows[currentIndex + 1].id }
+ }
+ return null
+ }
+ case 'ArrowLeft': {
+ if (!current) return { type: 'select', id: rows[0].id }
+ if (current.isExpanded) return { type: 'collapse', id: current.id }
+ if (current.parentId !== null && rowIndexById.has(current.parentId)) {
+ return { type: 'select', id: current.parentId }
+ }
+ return null
+ }
+ default:
+ return null
+ }
+}
+
+export const NAVIGATION_KEYS: ReadonlySet = new Set([
+ 'ArrowDown',
+ 'ArrowUp',
+ 'ArrowLeft',
+ 'ArrowRight',
+ 'Home',
+ 'End',
+])
+
+// Bar geometry
+
+export function computeBarGeometry(
+ startMs: number,
+ endMs: number,
+ window: TimeWindow
+): BarGeometry {
+ const [windowStart, windowEnd] = window
+ const duration = windowEnd - windowStart
+ const end = Math.max(startMs, endMs)
+ const isZeroDuration = end === startMs
+
+ if (!(duration > 0)) {
+ return { isVisible: false, isZeroDuration, clip: 'none', leftFraction: 0, widthFraction: 0 }
+ }
+
+ const startFraction = (startMs - windowStart) / duration
+ const endFraction = (end - windowStart) / duration
+
+ if (endFraction < 0 || startFraction > 1) {
+ return { isVisible: false, isZeroDuration, clip: 'none', leftFraction: 0, widthFraction: 0 }
+ }
+
+ const clipStart = startFraction < 0
+ const clipEnd = endFraction > 1
+ let clip: BarGeometry['clip'] = 'none'
+ if (clipStart && clipEnd) clip = 'both'
+ else if (clipStart) clip = 'start'
+ else if (clipEnd) clip = 'end'
+
+ const left = Math.max(0, startFraction)
+ const right = Math.min(1, endFraction)
+
+ return {
+ isVisible: true,
+ isZeroDuration,
+ clip,
+ leftFraction: left,
+ widthFraction: Math.max(0, right - left),
+ }
+}
+
+// Markers
+
+export function inferMarkerKind(event: SpanEvent): MarkerKind {
+ const name = event.name.toLowerCase()
+ const attributes = event.attributes ?? {}
+ if (name === 'exception' || name.includes('error') || 'exception.type' in attributes) {
+ return 'exception'
+ }
+ if (name === 'log' || name.startsWith('log.') || 'log.severity' in attributes) {
+ return 'log'
+ }
+ return 'milestone'
+}
+
+export const MARKER_KIND_LABEL: Record = {
+ log: 'Log',
+ exception: 'Exception',
+ milestone: 'Milestone',
+}
+
+// Key-value rendering
+
+const URL_PATTERN = /^https?:\/\/\S+$/i
+const ID_KEY_PATTERN = /(^|[._-])(span_?id|trace_?id|parent_?(span_?)?id|id)$/i
+const ERROR_KEY_PATTERN = /(exception|error|stack|stacktrace)/i
+
+export function detectValueKind(key: string, value: unknown): ValueKind {
+ if (value === null || value === undefined) return 'null'
+ if (typeof value === 'boolean') return 'boolean'
+ if (typeof value === 'number' || typeof value === 'bigint') return 'number'
+ if (typeof value === 'object') return 'json'
+ if (typeof value === 'string') {
+ if (URL_PATTERN.test(value)) return 'url'
+ if (ID_KEY_PATTERN.test(key)) return 'id'
+ if (ERROR_KEY_PATTERN.test(key)) return 'error'
+ return 'string'
+ }
+ return 'string'
+}
+
+export function stringifyValue(value: unknown): string {
+ if (value === null || value === undefined) return 'null'
+ if (typeof value === 'string') return value
+ if (typeof value === 'bigint') return value.toString()
+ if (typeof value === 'object') {
+ try {
+ return JSON.stringify(value, null, 2)
+ } catch {
+ return String(value)
+ }
+ }
+ return String(value)
+}
+
+export function summarizeJson(value: unknown): string {
+ if (Array.isArray(value)) {
+ return value.length === 1 ? 'Array (1 item)' : `Array (${value.length} items)`
+ }
+ if (value && typeof value === 'object') {
+ const size = Object.keys(value).length
+ return size === 1 ? 'Object (1 key)' : `Object (${size} keys)`
+ }
+ return 'Value'
+}
+
+export function sortedEntries(data: Record): Array<[string, unknown]> {
+ return Object.entries(data).sort(([a], [b]) => a.localeCompare(b))
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6abd4903100..cb962ada889 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2836,6 +2836,9 @@ importers:
'@tanstack/react-table':
specifier: 'catalog:'
version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@tanstack/react-virtual':
+ specifier: ^3.13.24
+ version: 3.14.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1