@assistant-ui/mcp-docs-server 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.docs/organized/code-examples/waterfall.md +801 -0
  2. package/.docs/organized/code-examples/with-ag-ui.md +38 -26
  3. package/.docs/organized/code-examples/with-ai-sdk-v6.md +38 -28
  4. package/.docs/organized/code-examples/with-artifacts.md +467 -0
  5. package/.docs/organized/code-examples/with-assistant-transport.md +31 -24
  6. package/.docs/organized/code-examples/with-chain-of-thought.md +41 -32
  7. package/.docs/organized/code-examples/with-cloud-standalone.md +675 -0
  8. package/.docs/organized/code-examples/with-cloud.md +34 -27
  9. package/.docs/organized/code-examples/with-custom-thread-list.md +34 -27
  10. package/.docs/organized/code-examples/with-elevenlabs-scribe.md +41 -30
  11. package/.docs/organized/code-examples/with-expo.md +2031 -0
  12. package/.docs/organized/code-examples/with-external-store.md +32 -25
  13. package/.docs/organized/code-examples/with-ffmpeg.md +31 -27
  14. package/.docs/organized/code-examples/with-langgraph.md +96 -38
  15. package/.docs/organized/code-examples/with-parent-id-grouping.md +32 -25
  16. package/.docs/organized/code-examples/with-react-hook-form.md +63 -58
  17. package/.docs/organized/code-examples/with-react-router.md +38 -30
  18. package/.docs/organized/code-examples/with-store.md +16 -24
  19. package/.docs/organized/code-examples/with-tanstack.md +36 -26
  20. package/.docs/organized/code-examples/with-tap-runtime.md +10 -24
  21. package/.docs/raw/docs/(docs)/cli.mdx +13 -6
  22. package/.docs/raw/docs/(docs)/guides/attachments.mdx +26 -3
  23. package/.docs/raw/docs/(docs)/guides/chain-of-thought.mdx +5 -5
  24. package/.docs/raw/docs/(docs)/guides/context-api.mdx +53 -52
  25. package/.docs/raw/docs/(docs)/guides/dictation.mdx +0 -2
  26. package/.docs/raw/docs/(docs)/guides/message-timing.mdx +169 -0
  27. package/.docs/raw/docs/(docs)/guides/quoting.mdx +327 -0
  28. package/.docs/raw/docs/(docs)/guides/speech.mdx +0 -1
  29. package/.docs/raw/docs/(docs)/index.mdx +12 -2
  30. package/.docs/raw/docs/(docs)/installation.mdx +8 -2
  31. package/.docs/raw/docs/(docs)/llm.mdx +9 -7
  32. package/.docs/raw/docs/(reference)/api-reference/primitives/action-bar-more.mdx +1 -1
  33. package/.docs/raw/docs/(reference)/api-reference/primitives/action-bar.mdx +2 -2
  34. package/.docs/raw/docs/(reference)/api-reference/primitives/assistant-if.mdx +27 -27
  35. package/.docs/raw/docs/(reference)/api-reference/primitives/composer.mdx +60 -0
  36. package/.docs/raw/docs/(reference)/api-reference/primitives/message-part.mdx +78 -4
  37. package/.docs/raw/docs/(reference)/api-reference/primitives/message.mdx +32 -0
  38. package/.docs/raw/docs/(reference)/api-reference/primitives/selection-toolbar.mdx +61 -0
  39. package/.docs/raw/docs/(reference)/api-reference/primitives/thread.mdx +1 -1
  40. package/.docs/raw/docs/(reference)/legacy/styled/assistant-modal.mdx +1 -6
  41. package/.docs/raw/docs/(reference)/legacy/styled/decomposition.mdx +2 -2
  42. package/.docs/raw/docs/(reference)/legacy/styled/markdown.mdx +1 -6
  43. package/.docs/raw/docs/(reference)/legacy/styled/thread.mdx +1 -5
  44. package/.docs/raw/docs/(reference)/migrations/v0-12.mdx +17 -17
  45. package/.docs/raw/docs/cloud/ai-sdk-assistant-ui.mdx +205 -0
  46. package/.docs/raw/docs/cloud/ai-sdk.mdx +292 -0
  47. package/.docs/raw/docs/cloud/authorization.mdx +178 -79
  48. package/.docs/raw/docs/cloud/{persistence/langgraph.mdx → langgraph.mdx} +2 -2
  49. package/.docs/raw/docs/cloud/overview.mdx +29 -39
  50. package/.docs/raw/docs/react-native/adapters.mdx +118 -0
  51. package/.docs/raw/docs/react-native/custom-backend.mdx +210 -0
  52. package/.docs/raw/docs/react-native/hooks.mdx +364 -0
  53. package/.docs/raw/docs/react-native/index.mdx +332 -0
  54. package/.docs/raw/docs/react-native/primitives.mdx +653 -0
  55. package/.docs/raw/docs/runtimes/ai-sdk/v6.mdx +7 -15
  56. package/.docs/raw/docs/runtimes/assistant-transport.mdx +103 -0
  57. package/.docs/raw/docs/runtimes/custom/external-store.mdx +25 -2
  58. package/.docs/raw/docs/runtimes/data-stream.mdx +1 -3
  59. package/.docs/raw/docs/runtimes/langgraph/index.mdx +113 -9
  60. package/.docs/raw/docs/runtimes/pick-a-runtime.mdx +1 -4
  61. package/.docs/raw/docs/ui/attachment.mdx +4 -2
  62. package/.docs/raw/docs/ui/message-timing.mdx +92 -0
  63. package/.docs/raw/docs/ui/part-grouping.mdx +1 -1
  64. package/.docs/raw/docs/ui/reasoning.mdx +4 -4
  65. package/.docs/raw/docs/ui/scrollbar.mdx +2 -2
  66. package/.docs/raw/docs/ui/syntax-highlighting.mdx +55 -50
  67. package/.docs/raw/docs/ui/thread.mdx +16 -9
  68. package/dist/index.d.ts +1 -1
  69. package/dist/index.d.ts.map +1 -1
  70. package/package.json +3 -3
  71. package/src/tools/tests/integration.test.ts +2 -2
  72. package/src/tools/tests/json-parsing.test.ts +1 -1
  73. package/src/tools/tests/mcp-protocol.test.ts +1 -3
  74. package/.docs/raw/docs/cloud/persistence/ai-sdk.mdx +0 -108
@@ -0,0 +1,801 @@
1
+ # Example: waterfall
2
+
3
+ ## app/globals.css
4
+
5
+ ```css
6
+ @import "tailwindcss";
7
+
8
+ :root {
9
+ --background: #ffffff;
10
+ --foreground: #171717;
11
+ --muted-foreground: #737373;
12
+ --border: #e5e5e5;
13
+ --accent: #f5f5f5;
14
+ }
15
+
16
+ @theme inline {
17
+ --color-background: var(--background);
18
+ --color-foreground: var(--foreground);
19
+ --color-muted-foreground: var(--muted-foreground);
20
+ --color-border: var(--border);
21
+ --color-accent: var(--accent);
22
+ --font-sans: var(--font-geist-sans);
23
+ --font-mono: var(--font-geist-mono);
24
+ }
25
+
26
+ @media (prefers-color-scheme: dark) {
27
+ :root {
28
+ --background: #0a0a0a;
29
+ --foreground: #ededed;
30
+ --muted-foreground: #a3a3a3;
31
+ --border: #2e2e2e;
32
+ --accent: #1a1a1a;
33
+ }
34
+ }
35
+
36
+ body {
37
+ background: var(--background);
38
+ color: var(--foreground);
39
+ font-family: Arial, Helvetica, sans-serif;
40
+ }
41
+
42
+ ```
43
+
44
+ ## app/layout.tsx
45
+
46
+ ```tsx
47
+ import type { Metadata } from "next";
48
+ import { Geist, Geist_Mono } from "next/font/google";
49
+ import "./globals.css";
50
+
51
+ const geistSans = Geist({
52
+ variable: "--font-geist-sans",
53
+ subsets: ["latin"],
54
+ });
55
+
56
+ const geistMono = Geist_Mono({
57
+ variable: "--font-geist-mono",
58
+ subsets: ["latin"],
59
+ });
60
+
61
+ export const metadata: Metadata = {
62
+ title: "Waterfall Example",
63
+ description: "Span waterfall visualization with @assistant-ui/react-o11y",
64
+ };
65
+
66
+ export default function RootLayout({
67
+ children,
68
+ }: Readonly<{
69
+ children: React.ReactNode;
70
+ }>) {
71
+ return (
72
+ <html lang="en">
73
+ <body
74
+ className={`${geistSans.variable} ${geistMono.variable} antialiased`}
75
+ >
76
+ {children}
77
+ </body>
78
+ </html>
79
+ );
80
+ }
81
+
82
+ ```
83
+
84
+ ## app/page.tsx
85
+
86
+ ```tsx
87
+ import { WaterfallPage } from "@/lib/waterfall-page";
88
+
89
+ export default function Home() {
90
+ return (
91
+ <div className="min-h-screen bg-background p-8">
92
+ <div className="mx-auto max-w-6xl">
93
+ <div className="mb-8">
94
+ <h1 className="mb-2 font-bold text-4xl text-foreground">
95
+ Waterfall Timeline
96
+ </h1>
97
+ <p className="text-lg text-muted-foreground">
98
+ Span visualization powered by @assistant-ui/react-o11y
99
+ </p>
100
+ </div>
101
+
102
+ <WaterfallPage />
103
+ </div>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ ```
109
+
110
+ ## lib/mock-data.ts
111
+
112
+ ```typescript
113
+ import type { SpanData } from "@assistant-ui/react-o11y";
114
+
115
+ const BASE = Date.now() - 5000;
116
+
117
+ export const mockSpans: SpanData[] = [
118
+ {
119
+ id: "span-1",
120
+ parentSpanId: null,
121
+ name: "POST /api/chat",
122
+ type: "api",
123
+ status: "completed",
124
+ startedAt: BASE,
125
+ endedAt: BASE + 4200,
126
+ latencyMs: 4200,
127
+ },
128
+ {
129
+ id: "span-2",
130
+ parentSpanId: "span-1",
131
+ name: "authenticate",
132
+ type: "action",
133
+ status: "completed",
134
+ startedAt: BASE + 50,
135
+ endedAt: BASE + 200,
136
+ latencyMs: 150,
137
+ },
138
+ {
139
+ id: "span-3",
140
+ parentSpanId: "span-1",
141
+ name: "agent-pipeline",
142
+ type: "pipeline",
143
+ status: "completed",
144
+ startedAt: BASE + 220,
145
+ endedAt: BASE + 4100,
146
+ latencyMs: 3880,
147
+ },
148
+ {
149
+ id: "span-4",
150
+ parentSpanId: "span-3",
151
+ name: "retrieve-context",
152
+ type: "tool",
153
+ status: "completed",
154
+ startedAt: BASE + 250,
155
+ endedAt: BASE + 900,
156
+ latencyMs: 650,
157
+ },
158
+ {
159
+ id: "span-5",
160
+ parentSpanId: "span-3",
161
+ name: "llm-generate",
162
+ type: "action",
163
+ status: "completed",
164
+ startedAt: BASE + 920,
165
+ endedAt: BASE + 2800,
166
+ latencyMs: 1880,
167
+ },
168
+ {
169
+ id: "span-6",
170
+ parentSpanId: "span-3",
171
+ name: "web-search",
172
+ type: "tool",
173
+ status: "completed",
174
+ startedAt: BASE + 2850,
175
+ endedAt: BASE + 3500,
176
+ latencyMs: 650,
177
+ },
178
+ {
179
+ id: "span-7",
180
+ parentSpanId: "span-3",
181
+ name: "llm-summarize",
182
+ type: "action",
183
+ status: "completed",
184
+ startedAt: BASE + 3520,
185
+ endedAt: BASE + 4050,
186
+ latencyMs: 530,
187
+ },
188
+ {
189
+ id: "span-8",
190
+ parentSpanId: "span-1",
191
+ name: "log-response",
192
+ type: "action",
193
+ status: "completed",
194
+ startedAt: BASE + 4110,
195
+ endedAt: BASE + 4180,
196
+ latencyMs: 70,
197
+ },
198
+ {
199
+ id: "span-9",
200
+ parentSpanId: null,
201
+ name: "POST /api/feedback",
202
+ type: "api",
203
+ status: "completed",
204
+ startedAt: BASE + 4500,
205
+ endedAt: BASE + 4700,
206
+ latencyMs: 200,
207
+ },
208
+ {
209
+ id: "span-10",
210
+ parentSpanId: "span-9",
211
+ name: "validate-input",
212
+ type: "action",
213
+ status: "completed",
214
+ startedAt: BASE + 4520,
215
+ endedAt: BASE + 4560,
216
+ latencyMs: 40,
217
+ },
218
+ {
219
+ id: "span-11",
220
+ parentSpanId: "span-9",
221
+ name: "store-feedback",
222
+ type: "action",
223
+ status: "failed",
224
+ startedAt: BASE + 4570,
225
+ endedAt: BASE + 4680,
226
+ latencyMs: 110,
227
+ },
228
+ {
229
+ id: "span-12",
230
+ parentSpanId: null,
231
+ name: "GET /api/status",
232
+ type: "api",
233
+ status: "running",
234
+ startedAt: BASE + 4800,
235
+ endedAt: null,
236
+ latencyMs: null,
237
+ },
238
+ {
239
+ id: "span-13",
240
+ parentSpanId: "span-12",
241
+ name: "health-check",
242
+ type: "flow",
243
+ status: "running",
244
+ startedAt: BASE + 4830,
245
+ endedAt: null,
246
+ latencyMs: null,
247
+ },
248
+ {
249
+ id: "span-14",
250
+ parentSpanId: "span-4",
251
+ name: "vector-search",
252
+ type: "tool",
253
+ status: "completed",
254
+ startedAt: BASE + 280,
255
+ endedAt: BASE + 700,
256
+ latencyMs: 420,
257
+ },
258
+ {
259
+ id: "span-15",
260
+ parentSpanId: "span-4",
261
+ name: "rerank",
262
+ type: "action",
263
+ status: "completed",
264
+ startedAt: BASE + 710,
265
+ endedAt: BASE + 880,
266
+ latencyMs: 170,
267
+ },
268
+ ];
269
+
270
+ ```
271
+
272
+ ## lib/waterfall-bar.tsx
273
+
274
+ ```tsx
275
+ "use client";
276
+
277
+ import { useCallback, useEffect, useRef } from "react";
278
+ import { useAuiState } from "@assistant-ui/store";
279
+ import type { SpanItemState } from "@assistant-ui/react-o11y";
280
+ import { useWaterfallLayout } from "./waterfall-timeline";
281
+
282
+ const TYPE_COLORS: Record<string, string> = {
283
+ action: "hsl(221 83% 53%)",
284
+ api: "hsl(262 83% 58%)",
285
+ tool: "hsl(142 71% 45%)",
286
+ flow: "hsl(25 95% 53%)",
287
+ pipeline: "hsl(340 75% 55%)",
288
+ };
289
+
290
+ const FALLBACK_COLOR = "hsl(220 9% 46%)";
291
+
292
+ const STATUS_OPACITY: Record<SpanItemState["status"], number> = {
293
+ running: 0.7,
294
+ completed: 1,
295
+ failed: 1,
296
+ skipped: 0.5,
297
+ };
298
+
299
+ export function WaterfallBar() {
300
+ const { barWidth, timeRange, barHeight } = useWaterfallLayout();
301
+ const startedAt = useAuiState((s) => s.span.startedAt);
302
+ const endedAt = useAuiState((s) => s.span.endedAt) as number | null;
303
+ const status = useAuiState((s) => s.span.status) as SpanItemState["status"];
304
+ const type = useAuiState((s) => s.span.type);
305
+
306
+ const barRef = useRef<SVGRectElement>(null);
307
+
308
+ const scale = useCallback(
309
+ (t: number) => {
310
+ const range = timeRange.max - timeRange.min || 1;
311
+ return ((t - timeRange.min) / range) * barWidth;
312
+ },
313
+ [timeRange, barWidth],
314
+ );
315
+
316
+ const x = scale(startedAt);
317
+
318
+ useEffect(() => {
319
+ if (status !== "running") return;
320
+
321
+ let frameId: number;
322
+ const tick = () => {
323
+ const width = scale(Date.now()) - x;
324
+ barRef.current?.setAttribute("width", String(Math.max(0, width)));
325
+ frameId = requestAnimationFrame(tick);
326
+ };
327
+ frameId = requestAnimationFrame(tick);
328
+ return () => cancelAnimationFrame(frameId);
329
+ }, [status, x, scale]);
330
+
331
+ const rawWidth = endedAt ? scale(endedAt) - x : 0;
332
+ const width = Math.max(rawWidth, 4);
333
+ const fill = TYPE_COLORS[type] ?? FALLBACK_COLOR;
334
+ const opacity = STATUS_OPACITY[status];
335
+
336
+ return (
337
+ <g>
338
+ <rect
339
+ ref={barRef}
340
+ x={x}
341
+ y={4}
342
+ width={width}
343
+ height={barHeight - 8}
344
+ rx={3}
345
+ fill={fill}
346
+ opacity={opacity}
347
+ className={status === "running" ? "animate-pulse" : ""}
348
+ />
349
+ {status === "failed" && (
350
+ <rect
351
+ x={x}
352
+ y={4}
353
+ width={width}
354
+ height={barHeight - 8}
355
+ rx={3}
356
+ fill="none"
357
+ stroke="hsl(0 84% 60%)"
358
+ strokeWidth={2}
359
+ />
360
+ )}
361
+ </g>
362
+ );
363
+ }
364
+
365
+ ```
366
+
367
+ ## lib/waterfall-page.tsx
368
+
369
+ ```tsx
370
+ "use client";
371
+
372
+ import { useAui, AuiProvider } from "@assistant-ui/store";
373
+ import { SpanResource } from "@assistant-ui/react-o11y";
374
+ import { mockSpans } from "./mock-data";
375
+ import { WaterfallTimeline } from "./waterfall-timeline";
376
+
377
+ export function WaterfallPage() {
378
+ const aui = useAui({
379
+ span: SpanResource({ spans: mockSpans }),
380
+ });
381
+
382
+ return (
383
+ <AuiProvider value={aui}>
384
+ <WaterfallTimeline />
385
+ </AuiProvider>
386
+ );
387
+ }
388
+
389
+ ```
390
+
391
+ ## lib/waterfall-row.tsx
392
+
393
+ ```tsx
394
+ "use client";
395
+
396
+ import { SpanPrimitive } from "@assistant-ui/react-o11y";
397
+ import { AuiIf } from "@assistant-ui/store";
398
+ import { WaterfallBar } from "./waterfall-bar";
399
+ import { useWaterfallLayout } from "./waterfall-timeline";
400
+
401
+ const LABEL_WIDTH = 200;
402
+
403
+ export function WaterfallRow() {
404
+ const { barWidth, contentWidth, barHeight } = useWaterfallLayout();
405
+
406
+ return (
407
+ <SpanPrimitive.Root
408
+ className="group flex cursor-pointer items-center"
409
+ style={{ width: contentWidth, height: barHeight }}
410
+ >
411
+ <SpanPrimitive.Indent
412
+ baseIndent={8}
413
+ indentPerLevel={12}
414
+ className="sticky left-0 z-10 flex shrink-0 items-center gap-1 overflow-hidden border-border border-r bg-background px-2 group-hover:bg-accent/50"
415
+ style={{ width: LABEL_WIDTH, height: barHeight }}
416
+ >
417
+ <AuiIf condition={(s) => s.span.hasChildren}>
418
+ <SpanPrimitive.CollapseToggle className="flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground">
419
+ <svg
420
+ className="size-3.5 transition-transform data-[collapsed=true]:[-rotate-90]"
421
+ viewBox="0 0 16 16"
422
+ fill="currentColor"
423
+ >
424
+ <path d="M4 6l4 4 4-4H4z" />
425
+ </svg>
426
+ </SpanPrimitive.CollapseToggle>
427
+ </AuiIf>
428
+ <AuiIf condition={(s) => !s.span.hasChildren}>
429
+ <span className="w-4.5 shrink-0" />
430
+ </AuiIf>
431
+ <SpanPrimitive.StatusIndicator className="size-1.5 shrink-0 rounded-full bg-current" />
432
+ <SpanPrimitive.TypeBadge className="shrink-0 rounded border border-border px-1 text-[10px] text-muted-foreground" />
433
+ <SpanPrimitive.Name className="truncate text-sm" />
434
+ </SpanPrimitive.Indent>
435
+
436
+ {/* Timeline bar */}
437
+ <div
438
+ className="group-hover:bg-accent/30"
439
+ style={{ width: barWidth, height: barHeight }}
440
+ >
441
+ <svg width={barWidth} height={barHeight}>
442
+ <WaterfallBar />
443
+ </svg>
444
+ </div>
445
+ </SpanPrimitive.Root>
446
+ );
447
+ }
448
+
449
+ ```
450
+
451
+ ## lib/waterfall-timeline.tsx
452
+
453
+ ```tsx
454
+ "use client";
455
+
456
+ import {
457
+ createContext,
458
+ useContext,
459
+ useEffect,
460
+ useLayoutEffect,
461
+ useMemo,
462
+ useRef,
463
+ useState,
464
+ } from "react";
465
+ import { useAuiState } from "@assistant-ui/store";
466
+ import { SpanPrimitive, type SpanState } from "@assistant-ui/react-o11y";
467
+ import { WaterfallRow } from "./waterfall-row";
468
+
469
+ const LABEL_WIDTH = 200;
470
+ const MAX_LIST_HEIGHT = 400;
471
+ const MIN_ZOOM = 1;
472
+ const MAX_ZOOM = 20;
473
+ const RIGHT_PADDING_RATIO = 0.08;
474
+
475
+ export type WaterfallLayoutContextValue = {
476
+ barWidth: number;
477
+ timeRange: { min: number; max: number };
478
+ barHeight: number;
479
+ contentWidth: number;
480
+ selectedSpanId: string | null;
481
+ onSelectSpan: (spanId: string) => void;
482
+ };
483
+
484
+ export const WaterfallLayoutContext =
485
+ createContext<WaterfallLayoutContextValue | null>(null);
486
+
487
+ export function useWaterfallLayout(): WaterfallLayoutContextValue {
488
+ const ctx = useContext(WaterfallLayoutContext);
489
+ if (!ctx) {
490
+ throw new Error(
491
+ "useWaterfallLayout must be used within WaterfallLayoutContext",
492
+ );
493
+ }
494
+ return ctx;
495
+ }
496
+
497
+ function formatTime(ms: number): string {
498
+ if (ms < 1000) return `${Math.round(ms)} ms`;
499
+ return `${(ms / 1000).toFixed(1)} s`;
500
+ }
501
+
502
+ function TimeAxisTicks({
503
+ timeRange,
504
+ barWidth,
505
+ }: {
506
+ timeRange: { min: number; max: number };
507
+ barWidth: number;
508
+ }) {
509
+ const duration = timeRange.max - timeRange.min;
510
+ const tickCount = Math.min(5, Math.max(2, Math.floor(barWidth / 100)));
511
+ const ticks = Array.from({ length: tickCount + 1 }, (_, i) => {
512
+ const t = (i / tickCount) * duration;
513
+ const x = (i / tickCount) * barWidth;
514
+ return { t, x };
515
+ });
516
+
517
+ return (
518
+ <svg width={barWidth} height={28} className="overflow-visible">
519
+ {ticks.map(({ t, x }) => (
520
+ <g key={x}>
521
+ <line
522
+ x1={x}
523
+ y1={20}
524
+ x2={x}
525
+ y2={28}
526
+ stroke="currentColor"
527
+ className="text-border"
528
+ />
529
+ <text
530
+ x={x}
531
+ y={14}
532
+ textAnchor="middle"
533
+ className="fill-muted-foreground text-[10px]"
534
+ >
535
+ {formatTime(t)}
536
+ </text>
537
+ </g>
538
+ ))}
539
+ </svg>
540
+ );
541
+ }
542
+
543
+ export function WaterfallTimeline() {
544
+ const outerRef = useRef<HTMLDivElement>(null);
545
+ const scrollRef = useRef<HTMLDivElement>(null);
546
+
547
+ const [baseBarWidth, setBaseBarWidth] = useState(400);
548
+ const [zoom, setZoom] = useState(1);
549
+ const zoomRef = useRef(1);
550
+ const baseBarWidthRef = useRef(400);
551
+ const scrollAdjustRef = useRef<{
552
+ mouseX: number;
553
+ ratio: number;
554
+ } | null>(null);
555
+
556
+ const [selectedSpanId, setSelectedSpanId] = useState<string | null>(null);
557
+
558
+ const barWidth = Math.max(200, Math.round(baseBarWidth * zoom));
559
+
560
+ const hasSpans = useAuiState((s) => s.span.hasChildren);
561
+ const timeRange = useAuiState(
562
+ (s) => s.span.timeRange,
563
+ ) as SpanState["timeRange"];
564
+
565
+ // Measure outer container width — re-attach when scroll container mounts
566
+ // biome-ignore lint/correctness/useExhaustiveDependencies: hasSpans triggers re-attach when scroll container mounts
567
+ useEffect(() => {
568
+ const el = outerRef.current;
569
+ if (!el) return;
570
+
571
+ const observer = new ResizeObserver((entries) => {
572
+ const entry = entries[0];
573
+ if (entry) {
574
+ const w = Math.max(200, entry.contentRect.width - LABEL_WIDTH);
575
+ baseBarWidthRef.current = w;
576
+ setBaseBarWidth(w);
577
+ }
578
+ });
579
+
580
+ observer.observe(el);
581
+ return () => observer.disconnect();
582
+ }, [hasSpans]);
583
+
584
+ // Cmd + scroll wheel zoom — re-attach when scroll container mounts
585
+ // biome-ignore lint/correctness/useExhaustiveDependencies: hasSpans triggers re-attach when scroll container mounts
586
+ useEffect(() => {
587
+ const el = scrollRef.current;
588
+ if (!el) return;
589
+
590
+ const handleWheel = (e: WheelEvent) => {
591
+ if (!e.metaKey) return;
592
+ e.preventDefault();
593
+
594
+ const rect = el.getBoundingClientRect();
595
+ const mouseXInView = e.clientX - rect.left - LABEL_WIDTH;
596
+ const mouseXInContent = el.scrollLeft + mouseXInView;
597
+ const currentBarWidth = baseBarWidthRef.current * zoomRef.current;
598
+ const ratio = currentBarWidth > 0 ? mouseXInContent / currentBarWidth : 0;
599
+
600
+ const newZoom = Math.max(
601
+ MIN_ZOOM,
602
+ Math.min(MAX_ZOOM, zoomRef.current * 2 ** (-e.deltaY * 0.003)),
603
+ );
604
+
605
+ scrollAdjustRef.current = { mouseX: mouseXInView, ratio };
606
+ zoomRef.current = newZoom;
607
+ setZoom(newZoom);
608
+ };
609
+
610
+ el.addEventListener("wheel", handleWheel, { passive: false });
611
+ return () => el.removeEventListener("wheel", handleWheel);
612
+ }, [hasSpans]);
613
+
614
+ // Adjust scroll after zoom
615
+ useLayoutEffect(() => {
616
+ const adj = scrollAdjustRef.current;
617
+ const el = scrollRef.current;
618
+ if (!adj || !el) return;
619
+ scrollAdjustRef.current = null;
620
+
621
+ const newBarX = adj.ratio * barWidth;
622
+ el.scrollLeft = newBarX - adj.mouseX;
623
+ }, [barWidth]);
624
+
625
+ const renderTimeRange = useMemo(() => {
626
+ const duration = timeRange.max - timeRange.min;
627
+ return {
628
+ min: timeRange.min,
629
+ max: timeRange.max + duration * RIGHT_PADDING_RATIO,
630
+ };
631
+ }, [timeRange]);
632
+
633
+ const contentWidth = LABEL_WIDTH + barWidth;
634
+ const scrollMaxHeight = MAX_LIST_HEIGHT + 28;
635
+
636
+ const layoutValue = useMemo(
637
+ () => ({
638
+ barWidth,
639
+ timeRange: renderTimeRange,
640
+ barHeight: 32,
641
+ contentWidth,
642
+ selectedSpanId,
643
+ onSelectSpan: setSelectedSpanId,
644
+ }),
645
+ [barWidth, renderTimeRange, contentWidth, selectedSpanId],
646
+ );
647
+
648
+ if (!hasSpans) {
649
+ return (
650
+ <div className="rounded-lg border border-border py-12 text-center text-muted-foreground text-sm">
651
+ No spans recorded.
652
+ </div>
653
+ );
654
+ }
655
+
656
+ return (
657
+ <div ref={outerRef} className="rounded-lg border border-border">
658
+ <div
659
+ ref={scrollRef}
660
+ className="overflow-auto"
661
+ style={{ maxHeight: scrollMaxHeight }}
662
+ >
663
+ {/* Time axis */}
664
+ <div
665
+ className="sticky top-0 z-20 flex border-border border-b bg-background"
666
+ style={{ width: contentWidth }}
667
+ >
668
+ <div
669
+ className="sticky left-0 z-30 shrink-0 border-border border-r bg-background px-2 py-1.5 text-muted-foreground text-xs"
670
+ style={{ width: LABEL_WIDTH }}
671
+ >
672
+ Span
673
+ </div>
674
+ <div style={{ width: barWidth, height: 28 }}>
675
+ <TimeAxisTicks timeRange={renderTimeRange} barWidth={barWidth} />
676
+ </div>
677
+ </div>
678
+
679
+ {/* Span rows */}
680
+ <WaterfallLayoutContext.Provider value={layoutValue}>
681
+ <div
682
+ style={{ width: contentWidth }}
683
+ onClick={(e) => {
684
+ const target = e.target as HTMLElement;
685
+ const el = target.closest("[data-span-id]") as HTMLElement | null;
686
+ if (el?.dataset.spanId) {
687
+ setSelectedSpanId(el.dataset.spanId);
688
+ }
689
+ }}
690
+ >
691
+ <SpanPrimitive.Children components={{ Span: WaterfallRow }} />
692
+ </div>
693
+ </WaterfallLayoutContext.Provider>
694
+ </div>
695
+
696
+ {/* Legend */}
697
+ <div className="flex items-center gap-4 border-border border-t px-3 py-2 text-muted-foreground text-xs">
698
+ <div className="flex items-center gap-1.5">
699
+ <span
700
+ className="size-2.5 rounded-sm"
701
+ style={{ background: "hsl(221 83% 53%)" }}
702
+ />
703
+ <span>action</span>
704
+ </div>
705
+ <div className="flex items-center gap-1.5">
706
+ <span
707
+ className="size-2.5 rounded-sm"
708
+ style={{ background: "hsl(262 83% 58%)" }}
709
+ />
710
+ <span>api</span>
711
+ </div>
712
+ <div className="flex items-center gap-1.5">
713
+ <span
714
+ className="size-2.5 rounded-sm"
715
+ style={{ background: "hsl(142 71% 45%)" }}
716
+ />
717
+ <span>tool</span>
718
+ </div>
719
+ <div className="flex items-center gap-1.5">
720
+ <span
721
+ className="size-2.5 rounded-sm"
722
+ style={{ background: "hsl(25 95% 53%)" }}
723
+ />
724
+ <span>flow</span>
725
+ </div>
726
+ <div className="flex items-center gap-1.5">
727
+ <span
728
+ className="size-2.5 rounded-sm"
729
+ style={{ background: "hsl(340 75% 55%)" }}
730
+ />
731
+ <span>pipeline</span>
732
+ </div>
733
+ </div>
734
+ </div>
735
+ );
736
+ }
737
+
738
+ ```
739
+
740
+ ## next.config.ts
741
+
742
+ ```typescript
743
+ import type { NextConfig } from "next";
744
+
745
+ const nextConfig: NextConfig = {
746
+ /* config options here */
747
+ };
748
+
749
+ export default nextConfig;
750
+
751
+ ```
752
+
753
+ ## package.json
754
+
755
+ ```json
756
+ {
757
+ "name": "waterfall",
758
+ "version": "0.0.0",
759
+ "private": true,
760
+ "type": "module",
761
+ "scripts": {
762
+ "dev": "next dev",
763
+ "build": "next build",
764
+ "start": "next start"
765
+ },
766
+ "dependencies": {
767
+ "@assistant-ui/react-o11y": "workspace:*",
768
+ "@assistant-ui/store": "workspace:*",
769
+ "@assistant-ui/tap": "workspace:*",
770
+ "next": "^16.1.6",
771
+ "react": "^19.2.4",
772
+ "react-dom": "^19.2.4"
773
+ },
774
+ "devDependencies": {
775
+ "@assistant-ui/x-buildutils": "workspace:*",
776
+ "@tailwindcss/postcss": "^4.2.1",
777
+ "@types/node": "^25.3.0",
778
+ "@types/react": "^19.2.14",
779
+ "@types/react-dom": "^19.2.3",
780
+ "postcss": "^8.5.6",
781
+ "tailwindcss": "^4.2.1",
782
+ "typescript": "^5.9.3"
783
+ }
784
+ }
785
+
786
+ ```
787
+
788
+ ## tsconfig.json
789
+
790
+ ```json
791
+ {
792
+ "extends": "@assistant-ui/x-buildutils/ts/next",
793
+ "compilerOptions": {
794
+ "paths": { "@/*": ["./*"] }
795
+ },
796
+ "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
797
+ "exclude": ["node_modules"]
798
+ }
799
+
800
+ ```
801
+