@session-link/viewer 0.1.0

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.
package/RunViewer.tsx ADDED
@@ -0,0 +1,1265 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import {
5
+ Bot,
6
+ Braces,
7
+ Check,
8
+ ChevronDown,
9
+ ChevronRight,
10
+ Copy,
11
+ Search,
12
+ User,
13
+ Wrench,
14
+ } from "lucide-react";
15
+ import type { ContentPart, Message, Run, Span } from "@session-link/format";
16
+
17
+ /* ---------------------------------------------------------------- tokens */
18
+
19
+ const T = {
20
+ paper: "#f5f6f3",
21
+ panel: "#fdfdfb",
22
+ soft: "#eef1ec",
23
+ ink: "#17201c",
24
+ faint: "#5b6660",
25
+ line: "#d8ddd7",
26
+ signal: "#0e6f5c",
27
+ error: "#b3402e",
28
+ serif:
29
+ '"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif',
30
+ sans: 'system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
31
+ mono: 'ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace',
32
+ };
33
+
34
+ // Record<string, ...> on purpose: the wire format is open, so spans with
35
+ // types this build doesn't know can arrive — they take the `custom` hue.
36
+ const HUES: Record<string, string> = {
37
+ llm_call: T.signal,
38
+ tool_call: "#a16418",
39
+ retrieval: "#28629c",
40
+ agent: "#5b4b8a",
41
+ custom: "#5b6660",
42
+ };
43
+
44
+ const spanHue = (s: Span) =>
45
+ s.status === "error" ? T.error : (HUES[s.type] ?? HUES.custom);
46
+
47
+ /* --------------------------------------------------------------- helpers */
48
+
49
+ const ts = (s?: string) => (s ? new Date(s).getTime() : NaN);
50
+
51
+ function fmtDur(msVal: number): string {
52
+ if (!Number.isFinite(msVal)) return "";
53
+ if (msVal < 1000) return `${Math.round(msVal)}ms`;
54
+ return `${(msVal / 1000).toFixed(2)}s`;
55
+ }
56
+
57
+ const fmtInt = (n: number) => n.toLocaleString("en-US");
58
+
59
+ const fmtCost = (n: number) => (n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`);
60
+
61
+ const spanDur = (s: Span) => ts(s.ended_at) - ts(s.started_at);
62
+
63
+ const spanLabel = (s: Span) => s.name || s.type;
64
+
65
+ function stringify(v: unknown): string {
66
+ try {
67
+ return JSON.stringify(v, null, 2) ?? String(v);
68
+ } catch {
69
+ return String(v);
70
+ }
71
+ }
72
+
73
+ interface RunIndex {
74
+ roots: Span[];
75
+ children: Map<string, Span[]>;
76
+ byId: Map<string, Span>;
77
+ t0: number;
78
+ t1: number;
79
+ models: string[];
80
+ tokensIn?: number;
81
+ tokensOut?: number;
82
+ cost?: number;
83
+ durMs?: number;
84
+ }
85
+
86
+ function indexRun(run: Run): RunIndex {
87
+ const byId = new Map<string, Span>();
88
+ for (const s of run.spans) byId.set(s.id, s);
89
+
90
+ const children = new Map<string, Span[]>();
91
+ const roots: Span[] = [];
92
+ for (const s of run.spans) {
93
+ const pid = s.parent_id ?? null;
94
+ if (pid && byId.has(pid)) {
95
+ const list = children.get(pid) ?? [];
96
+ list.push(s);
97
+ children.set(pid, list);
98
+ } else {
99
+ roots.push(s);
100
+ }
101
+ }
102
+
103
+ let t0 = Infinity;
104
+ let t1 = -Infinity;
105
+ for (const s of run.spans) {
106
+ const a = ts(s.started_at);
107
+ const b = ts(s.ended_at);
108
+ if (Number.isFinite(a)) t0 = Math.min(t0, a);
109
+ if (Number.isFinite(b)) t1 = Math.max(t1, b);
110
+ }
111
+ const hasTimeline = Number.isFinite(t0) && Number.isFinite(t1) && t1 > t0;
112
+
113
+ const models: string[] = [];
114
+ let tin = 0;
115
+ let tout = 0;
116
+ let cost = 0;
117
+ let sawUsage = false;
118
+ let sawCost = false;
119
+ for (const s of run.spans) {
120
+ if (s.type !== "llm_call") continue;
121
+ const label = `${s.model.provider ? s.model.provider + "/" : ""}${s.model.id}`;
122
+ if (!models.includes(label)) models.push(label);
123
+ if (s.usage) {
124
+ sawUsage = true;
125
+ tin += s.usage.input_tokens ?? 0;
126
+ tout += s.usage.output_tokens ?? 0;
127
+ if (s.usage.cost_usd != null) {
128
+ sawCost = true;
129
+ cost += s.usage.cost_usd;
130
+ }
131
+ }
132
+ }
133
+
134
+ return {
135
+ roots,
136
+ children,
137
+ byId,
138
+ t0,
139
+ t1,
140
+ models,
141
+ tokensIn: run.metrics?.input_tokens ?? (sawUsage ? tin : undefined),
142
+ tokensOut: run.metrics?.output_tokens ?? (sawUsage ? tout : undefined),
143
+ cost: run.metrics?.cost_usd ?? (sawCost ? cost : undefined),
144
+ durMs: run.metrics?.latency_ms ?? (hasTimeline ? t1 - t0 : undefined),
145
+ };
146
+ }
147
+
148
+ /* ---------------------------------------------------------------- atoms */
149
+
150
+ function Eyebrow({ children }: { children: React.ReactNode }) {
151
+ return (
152
+ <div
153
+ style={{
154
+ fontFamily: T.mono,
155
+ fontSize: 11,
156
+ letterSpacing: "0.14em",
157
+ textTransform: "uppercase",
158
+ color: T.faint,
159
+ }}
160
+ >
161
+ {children}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ function Chip({
167
+ children,
168
+ mono = true,
169
+ title,
170
+ }: {
171
+ children: React.ReactNode;
172
+ mono?: boolean;
173
+ title?: string;
174
+ }) {
175
+ return (
176
+ <span
177
+ title={title}
178
+ style={{
179
+ display: "inline-flex",
180
+ alignItems: "center",
181
+ gap: 6,
182
+ border: `1px solid ${T.line}`,
183
+ borderRadius: 999,
184
+ padding: "3px 10px",
185
+ fontFamily: mono ? T.mono : T.sans,
186
+ fontSize: 12,
187
+ color: T.ink,
188
+ background: T.panel,
189
+ }}
190
+ >
191
+ {children}
192
+ </span>
193
+ );
194
+ }
195
+
196
+ function Metric({ label, value }: { label: string; value: string }) {
197
+ return (
198
+ <div style={{ minWidth: 84 }}>
199
+ <div style={{ fontFamily: T.mono, fontSize: 15 }}>{value}</div>
200
+ <div
201
+ style={{
202
+ fontFamily: T.mono,
203
+ fontSize: 10,
204
+ letterSpacing: "0.12em",
205
+ textTransform: "uppercase",
206
+ color: T.faint,
207
+ marginTop: 2,
208
+ }}
209
+ >
210
+ {label}
211
+ </div>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ function SectionLabel({ children }: { children: React.ReactNode }) {
217
+ return (
218
+ <div
219
+ style={{
220
+ fontFamily: T.mono,
221
+ fontSize: 10,
222
+ letterSpacing: "0.14em",
223
+ textTransform: "uppercase",
224
+ color: T.faint,
225
+ margin: "18px 0 8px",
226
+ }}
227
+ >
228
+ {children}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ /** Placeholder for a content-addressed payload whose bytes live in the
234
+ * attachments manifest. Mirrors the image-attachment placeholder. */
235
+ function BlobRefNote({ label, hash }: { label: string; hash: string }) {
236
+ return (
237
+ <div
238
+ style={{
239
+ fontFamily: T.mono,
240
+ fontSize: 11,
241
+ color: T.faint,
242
+ border: `1px dashed ${T.line}`,
243
+ borderRadius: 6,
244
+ padding: "8px 12px",
245
+ }}
246
+ >
247
+ {label} · {hash.slice(0, 26)}… (content-addressed, bytes not inlined)
248
+ </div>
249
+ );
250
+ }
251
+
252
+ function JsonBlock({ value, tall = false }: { value: unknown; tall?: boolean }) {
253
+ return (
254
+ <pre
255
+ style={{
256
+ fontFamily: T.mono,
257
+ fontSize: 12,
258
+ lineHeight: 1.55,
259
+ background: T.soft,
260
+ borderRadius: 6,
261
+ padding: "10px 12px",
262
+ overflow: "auto",
263
+ maxHeight: tall ? 560 : 280,
264
+ whiteSpace: "pre",
265
+ }}
266
+ >
267
+ {stringify(value)}
268
+ </pre>
269
+ );
270
+ }
271
+
272
+ /* ----------------------------------------------------- message rendering */
273
+
274
+ function PartView({ part }: { part: ContentPart }) {
275
+ switch (part.type) {
276
+ case "text":
277
+ return (
278
+ <div style={{ fontSize: 13.5, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>
279
+ {part.text}
280
+ </div>
281
+ );
282
+ case "thinking":
283
+ return (
284
+ <details>
285
+ <summary
286
+ style={{
287
+ cursor: "pointer",
288
+ fontFamily: T.mono,
289
+ fontSize: 11,
290
+ letterSpacing: "0.1em",
291
+ textTransform: "uppercase",
292
+ color: T.faint,
293
+ }}
294
+ >
295
+ thinking
296
+ </summary>
297
+ <div
298
+ style={{
299
+ fontFamily: T.serif,
300
+ fontStyle: "italic",
301
+ fontSize: 13.5,
302
+ lineHeight: 1.6,
303
+ color: T.faint,
304
+ whiteSpace: "pre-wrap",
305
+ borderLeft: `2px solid ${T.line}`,
306
+ padding: "4px 0 4px 12px",
307
+ margin: "8px 0 0",
308
+ }}
309
+ >
310
+ {part.text}
311
+ </div>
312
+ </details>
313
+ );
314
+ case "tool_call":
315
+ return (
316
+ <div style={{ border: `1px solid ${T.line}`, borderRadius: 6 }}>
317
+ <div
318
+ style={{
319
+ display: "flex",
320
+ gap: 8,
321
+ alignItems: "baseline",
322
+ padding: "6px 12px",
323
+ borderBottom: `1px solid ${T.line}`,
324
+ fontFamily: T.mono,
325
+ fontSize: 12,
326
+ }}
327
+ >
328
+ <span style={{ color: HUES.tool_call }}>→ {part.name}</span>
329
+ <span style={{ color: T.faint }}>{part.id}</span>
330
+ </div>
331
+ <div style={{ padding: 10 }}>
332
+ <JsonBlock value={part.arguments} />
333
+ </div>
334
+ </div>
335
+ );
336
+ case "tool_result":
337
+ return (
338
+ <div style={{ border: `1px solid ${T.line}`, borderRadius: 6 }}>
339
+ <div
340
+ style={{
341
+ display: "flex",
342
+ gap: 8,
343
+ alignItems: "baseline",
344
+ padding: "6px 12px",
345
+ borderBottom: `1px solid ${T.line}`,
346
+ fontFamily: T.mono,
347
+ fontSize: 12,
348
+ }}
349
+ >
350
+ <span style={{ color: part.is_error ? T.error : HUES.tool_call }}>
351
+ ← result
352
+ </span>
353
+ <span style={{ color: T.faint }}>{part.tool_call_id}</span>
354
+ {part.is_error && <span style={{ color: T.error }}>error</span>}
355
+ </div>
356
+ <div style={{ padding: 10, display: "grid", gap: 8 }}>
357
+ {part.content.map((p, i) => (
358
+ <PartView key={i} part={p} />
359
+ ))}
360
+ </div>
361
+ </div>
362
+ );
363
+ case "image":
364
+ return part.url ? (
365
+ // eslint-disable-next-line @next/next/no-img-element
366
+ <img
367
+ src={part.url}
368
+ alt="attachment"
369
+ style={{
370
+ maxWidth: 360,
371
+ borderRadius: 6,
372
+ border: `1px solid ${T.line}`,
373
+ display: "block",
374
+ }}
375
+ />
376
+ ) : (
377
+ <div
378
+ style={{
379
+ fontFamily: T.mono,
380
+ fontSize: 11,
381
+ color: T.faint,
382
+ border: `1px dashed ${T.line}`,
383
+ borderRadius: 6,
384
+ padding: "8px 12px",
385
+ }}
386
+ >
387
+ image · {(part.attachment ?? "no source").slice(0, 26)}…
388
+ </div>
389
+ );
390
+ case "data":
391
+ return <JsonBlock value={part.data} />;
392
+ case "blob":
393
+ return (
394
+ <BlobRefNote
395
+ label={part.name ?? part.mime ?? "blob"}
396
+ hash={part.attachment}
397
+ />
398
+ );
399
+ default:
400
+ /* progressive enhancement in miniature: never drop unknown parts */
401
+ return <JsonBlock value={part} />;
402
+ }
403
+ }
404
+
405
+ function RoleGutter({ role }: { role: Message["role"] }) {
406
+ const icon =
407
+ role === "assistant" ? (
408
+ <Bot size={13} />
409
+ ) : role === "user" ? (
410
+ <User size={13} />
411
+ ) : role === "tool" ? (
412
+ <Wrench size={13} />
413
+ ) : (
414
+ <Braces size={13} />
415
+ );
416
+ return (
417
+ <div
418
+ style={{
419
+ width: 88,
420
+ flexShrink: 0,
421
+ display: "flex",
422
+ alignItems: "center",
423
+ gap: 6,
424
+ fontFamily: T.mono,
425
+ fontSize: 10,
426
+ letterSpacing: "0.12em",
427
+ textTransform: "uppercase",
428
+ color: T.faint,
429
+ paddingTop: 2,
430
+ }}
431
+ >
432
+ {icon}
433
+ {role}
434
+ </div>
435
+ );
436
+ }
437
+
438
+ function MessageView({ msg }: { msg: Message }) {
439
+ return (
440
+ <div
441
+ style={{
442
+ display: "flex",
443
+ gap: 12,
444
+ padding: "12px 0",
445
+ borderTop: `1px solid ${T.line}`,
446
+ alignItems: "flex-start",
447
+ }}
448
+ >
449
+ <RoleGutter role={msg.role} />
450
+ <div style={{ display: "grid", gap: 10, minWidth: 0, flex: 1 }}>
451
+ {msg.content.map((p, i) => (
452
+ <PartView key={i} part={p} />
453
+ ))}
454
+ </div>
455
+ </div>
456
+ );
457
+ }
458
+
459
+ /* ------------------------------------------------------------ span detail */
460
+
461
+ function LlmDetail({ span }: { span: Extract<Span, { type: "llm_call" }> }) {
462
+ return (
463
+ <>
464
+ <div
465
+ style={{
466
+ display: "flex",
467
+ flexWrap: "wrap",
468
+ gap: 6,
469
+ alignItems: "center",
470
+ }}
471
+ >
472
+ <Chip>
473
+ {span.model.provider ? `${span.model.provider}/` : ""}
474
+ {span.model.id}
475
+ </Chip>
476
+ {span.params &&
477
+ Object.entries(span.params).map(([k, v]) => (
478
+ <span
479
+ key={k}
480
+ style={{
481
+ fontFamily: T.mono,
482
+ fontSize: 11.5,
483
+ color: T.faint,
484
+ background: T.soft,
485
+ borderRadius: 5,
486
+ padding: "3px 8px",
487
+ }}
488
+ >
489
+ {k}{" "}
490
+ <span style={{ color: T.ink }}>
491
+ {typeof v === "object" ? stringify(v) : String(v)}
492
+ </span>
493
+ </span>
494
+ ))}
495
+ </div>
496
+
497
+ {span.input.system ? (
498
+ <>
499
+ <SectionLabel>system</SectionLabel>
500
+ <div
501
+ style={{
502
+ fontSize: 13,
503
+ lineHeight: 1.6,
504
+ whiteSpace: "pre-wrap",
505
+ background: T.soft,
506
+ border: `1px solid ${T.line}`,
507
+ borderRadius: 6,
508
+ padding: "10px 12px",
509
+ }}
510
+ >
511
+ {span.input.system}
512
+ </div>
513
+ </>
514
+ ) : (
515
+ span.input.system_ref && (
516
+ <>
517
+ <SectionLabel>system</SectionLabel>
518
+ <BlobRefNote label="system" hash={span.input.system_ref} />
519
+ </>
520
+ )
521
+ )}
522
+
523
+ {span.input.tools && span.input.tools.length > 0 ? (
524
+ <>
525
+ <SectionLabel>tools</SectionLabel>
526
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
527
+ {span.input.tools.map((t) => (
528
+ <Chip key={t.name} title={t.description}>
529
+ <Wrench size={11} /> {t.name}
530
+ </Chip>
531
+ ))}
532
+ </div>
533
+ </>
534
+ ) : (
535
+ span.input.tools_ref && (
536
+ <>
537
+ <SectionLabel>tools</SectionLabel>
538
+ <BlobRefNote label="tools" hash={span.input.tools_ref} />
539
+ </>
540
+ )
541
+ )}
542
+
543
+ <SectionLabel>input</SectionLabel>
544
+ <div>
545
+ {(span.input.messages ?? []).map((m, i) => (
546
+ <MessageView key={i} msg={m} />
547
+ ))}
548
+ {!span.input.messages && span.input.messages_ref && (
549
+ <BlobRefNote label="messages" hash={span.input.messages_ref} />
550
+ )}
551
+ </div>
552
+
553
+ <SectionLabel>output</SectionLabel>
554
+ <div>
555
+ {span.output.messages.map((m, i) => (
556
+ <MessageView key={i} msg={m} />
557
+ ))}
558
+ </div>
559
+
560
+ {span.usage && (
561
+ <div
562
+ style={{
563
+ marginTop: 16,
564
+ paddingTop: 12,
565
+ borderTop: `1px solid ${T.line}`,
566
+ fontFamily: T.mono,
567
+ fontSize: 12,
568
+ color: T.faint,
569
+ }}
570
+ >
571
+ {span.usage.input_tokens != null && `${fmtInt(span.usage.input_tokens)} in`}
572
+ {span.usage.output_tokens != null &&
573
+ ` · ${fmtInt(span.usage.output_tokens)} out`}
574
+ {span.usage.cache_read_tokens != null &&
575
+ ` · ${fmtInt(span.usage.cache_read_tokens)} cached`}
576
+ {span.usage.cost_usd != null && ` · ${fmtCost(span.usage.cost_usd)}`}
577
+ </div>
578
+ )}
579
+ </>
580
+ );
581
+ }
582
+
583
+ function SpanDetail({ span }: { span: Span }) {
584
+ switch (span.type) {
585
+ case "llm_call":
586
+ return <LlmDetail span={span} />;
587
+ case "tool_call":
588
+ return (
589
+ <>
590
+ <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
591
+ <Chip>
592
+ <Wrench size={11} /> {span.input.name}
593
+ </Chip>
594
+ {span.input.tool_call_id && (
595
+ <span style={{ fontFamily: T.mono, fontSize: 12, color: T.faint }}>
596
+ {span.input.tool_call_id}
597
+ </span>
598
+ )}
599
+ </div>
600
+ <SectionLabel>arguments</SectionLabel>
601
+ <JsonBlock value={span.input.arguments} />
602
+ <SectionLabel>result</SectionLabel>
603
+ {span.output?.result === undefined && span.output?.result_ref ? (
604
+ <BlobRefNote label="result" hash={span.output.result_ref} />
605
+ ) : (
606
+ <JsonBlock value={span.output?.result} />
607
+ )}
608
+ </>
609
+ );
610
+ case "retrieval":
611
+ return (
612
+ <>
613
+ <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
614
+ <Search size={13} color={HUES.retrieval} />
615
+ <span style={{ fontFamily: T.mono, fontSize: 13 }}>
616
+ {span.input.query}
617
+ </span>
618
+ </div>
619
+ <SectionLabel>documents</SectionLabel>
620
+ <div style={{ display: "grid", gap: 8 }}>
621
+ {(span.output?.documents ?? []).map((d, i) => (
622
+ <div
623
+ key={i}
624
+ style={{
625
+ border: `1px solid ${T.line}`,
626
+ borderRadius: 6,
627
+ padding: "8px 12px",
628
+ }}
629
+ >
630
+ <div
631
+ style={{
632
+ display: "flex",
633
+ justifyContent: "space-between",
634
+ fontFamily: T.mono,
635
+ fontSize: 11,
636
+ color: T.faint,
637
+ marginBottom: d.text ? 6 : 0,
638
+ }}
639
+ >
640
+ <span>{d.ref ?? `doc ${i + 1}`}</span>
641
+ {d.score != null && <span>{d.score.toFixed(3)}</span>}
642
+ </div>
643
+ {d.text && (
644
+ <div
645
+ style={{
646
+ fontSize: 13,
647
+ lineHeight: 1.55,
648
+ whiteSpace: "pre-wrap",
649
+ maxHeight: 120,
650
+ overflow: "auto",
651
+ }}
652
+ >
653
+ {d.text}
654
+ </div>
655
+ )}
656
+ </div>
657
+ ))}
658
+ </div>
659
+ </>
660
+ );
661
+ default:
662
+ return (
663
+ <>
664
+ {span.input != null && (
665
+ <>
666
+ <SectionLabel>input</SectionLabel>
667
+ <JsonBlock value={span.input} />
668
+ </>
669
+ )}
670
+ {span.output != null && (
671
+ <>
672
+ <SectionLabel>output</SectionLabel>
673
+ <JsonBlock value={span.output} />
674
+ </>
675
+ )}
676
+ </>
677
+ );
678
+ }
679
+ }
680
+
681
+ /* -------------------------------------------------------------- the tree */
682
+
683
+ function TreeRow({
684
+ span,
685
+ depth,
686
+ idx,
687
+ selected,
688
+ hasKids,
689
+ open,
690
+ onToggle,
691
+ onSelect,
692
+ }: {
693
+ span: Span;
694
+ depth: number;
695
+ idx: RunIndex;
696
+ selected: boolean;
697
+ hasKids: boolean;
698
+ open: boolean;
699
+ onToggle: () => void;
700
+ onSelect: () => void;
701
+ }) {
702
+ const dur = spanDur(span);
703
+ const hasTimeline = idx.t1 > idx.t0;
704
+ const a = ts(span.started_at);
705
+ const showBar = hasTimeline && Number.isFinite(a) && Number.isFinite(dur);
706
+ const left = showBar ? ((a - idx.t0) / (idx.t1 - idx.t0)) * 100 : 0;
707
+ const width = showBar ? Math.max(1.5, (dur / (idx.t1 - idx.t0)) * 100) : 0;
708
+ const hue = spanHue(span);
709
+
710
+ return (
711
+ <div
712
+ id={`rv-row-${span.id}`}
713
+ role="button"
714
+ tabIndex={0}
715
+ title={
716
+ span.started_at
717
+ ? `${spanLabel(span)} · ${new Date(span.started_at).toLocaleTimeString()}${Number.isFinite(dur) ? ` · ${fmtDur(dur)}` : ""}`
718
+ : spanLabel(span)
719
+ }
720
+ onClick={onSelect}
721
+ onKeyDown={(e) => {
722
+ if (e.key === "Enter" || e.key === " ") {
723
+ e.preventDefault();
724
+ onSelect();
725
+ }
726
+ }}
727
+ className="rv-row"
728
+ style={{
729
+ display: "flex",
730
+ alignItems: "center",
731
+ gap: 6,
732
+ padding: "7px 12px 7px 0",
733
+ paddingLeft: 10 + depth * 14,
734
+ cursor: "pointer",
735
+ background: selected ? "rgba(14,111,92,0.08)" : "transparent",
736
+ boxShadow: selected ? `inset 2px 0 0 ${T.signal}` : "none",
737
+ }}
738
+ >
739
+ {hasKids ? (
740
+ <button
741
+ aria-label={open ? "Collapse" : "Expand"}
742
+ onClick={(e) => {
743
+ e.stopPropagation();
744
+ onToggle();
745
+ }}
746
+ style={{
747
+ border: "none",
748
+ background: "none",
749
+ padding: 0,
750
+ width: 16,
751
+ height: 16,
752
+ display: "flex",
753
+ alignItems: "center",
754
+ justifyContent: "center",
755
+ cursor: "pointer",
756
+ color: T.faint,
757
+ }}
758
+ >
759
+ {open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
760
+ </button>
761
+ ) : (
762
+ <span style={{ width: 16 }} />
763
+ )}
764
+ <span
765
+ style={{
766
+ width: 8,
767
+ height: 8,
768
+ borderRadius: 2,
769
+ background: hue,
770
+ flexShrink: 0,
771
+ }}
772
+ />
773
+ <span
774
+ style={{
775
+ flex: 1,
776
+ minWidth: 0,
777
+ fontSize: 13,
778
+ whiteSpace: "nowrap",
779
+ overflow: "hidden",
780
+ textOverflow: "ellipsis",
781
+ }}
782
+ >
783
+ {spanLabel(span)}
784
+ </span>
785
+ {hasTimeline && (
786
+ <span
787
+ aria-hidden
788
+ style={{
789
+ width: 60,
790
+ height: 4,
791
+ borderRadius: 2,
792
+ background: T.soft,
793
+ position: "relative",
794
+ flexShrink: 0,
795
+ overflow: "hidden",
796
+ }}
797
+ >
798
+ {showBar && (
799
+ <span
800
+ style={{
801
+ position: "absolute",
802
+ left: `${left}%`,
803
+ width: `${width}%`,
804
+ top: 0,
805
+ bottom: 0,
806
+ borderRadius: 2,
807
+ background: hue,
808
+ }}
809
+ />
810
+ )}
811
+ </span>
812
+ )}
813
+ {hasTimeline && (
814
+ <span
815
+ style={{
816
+ width: 54,
817
+ textAlign: "right",
818
+ fontFamily: T.mono,
819
+ fontSize: 11,
820
+ color: T.faint,
821
+ flexShrink: 0,
822
+ }}
823
+ >
824
+ {fmtDur(dur)}
825
+ </span>
826
+ )}
827
+ </div>
828
+ );
829
+ }
830
+
831
+ /* ------------------------------------------------------------- component */
832
+
833
+ export function RunViewer({ run }: { run: Run }) {
834
+ const idx = useMemo(() => indexRun(run), [run]);
835
+ const [sel, setSel] = useState<string | null>(idx.roots[0]?.id ?? null);
836
+ const [closed, setClosed] = useState<Set<string>>(new Set());
837
+ const [raw, setRaw] = useState(false);
838
+ const [copied, setCopied] = useState(false);
839
+
840
+ /* deep link: read #span=<id> on mount, write it on select */
841
+ useEffect(() => {
842
+ try {
843
+ const m = window.location.hash.match(/span=([\w.-]+)/);
844
+ if (m && idx.byId.has(m[1])) {
845
+ setSel(m[1]);
846
+ document
847
+ .getElementById(`rv-row-${m[1]}`)
848
+ ?.scrollIntoView({ block: "nearest" });
849
+ }
850
+ } catch {
851
+ /* sandboxed embeds may not expose location — fine */
852
+ }
853
+ }, [idx]);
854
+
855
+ const select = (id: string) => {
856
+ setSel(id);
857
+ setCopied(false);
858
+ try {
859
+ history.replaceState(null, "", `#span=${id}`);
860
+ } catch {
861
+ /* ignore in sandboxes */
862
+ }
863
+ };
864
+
865
+ const copyLink = () => {
866
+ if (!sel) return;
867
+ let text = `#span=${sel}`;
868
+ try {
869
+ const u = new URL(window.location.href);
870
+ u.hash = `span=${sel}`;
871
+ text = u.toString();
872
+ } catch {
873
+ /* keep relative anchor */
874
+ }
875
+ navigator.clipboard?.writeText(text).catch(() => {});
876
+ setCopied(true);
877
+ window.setTimeout(() => setCopied(false), 1400);
878
+ };
879
+
880
+ const rows: Array<{ span: Span; depth: number }> = [];
881
+ const walk = (list: Span[], depth: number) => {
882
+ for (const s of list) {
883
+ rows.push({ span: s, depth });
884
+ if (!closed.has(s.id)) walk(idx.children.get(s.id) ?? [], depth + 1);
885
+ }
886
+ };
887
+ walk(idx.roots, 0);
888
+
889
+ const selected = sel ? idx.byId.get(sel) : undefined;
890
+ const created = new Date(run.created_at);
891
+
892
+ const typeCounts: Array<[string, number]> = [];
893
+ let errorCount = 0;
894
+ for (const s of run.spans) {
895
+ if (s.status === "error") errorCount++;
896
+ const entry = typeCounts.find(([t]) => t === s.type);
897
+ if (entry) entry[1]++;
898
+ else typeCounts.push([s.type, 1]);
899
+ }
900
+
901
+ /* keyboard: ↑/↓ move through visible rows, ←/→ collapse/expand. The
902
+ handler lives on the rows container so it fires while a row has focus. */
903
+ const onTreeKey = (e: React.KeyboardEvent) => {
904
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
905
+ e.preventDefault();
906
+ const at = rows.findIndex((r) => r.span.id === sel);
907
+ const next =
908
+ rows[
909
+ Math.min(rows.length - 1, Math.max(0, at + (e.key === "ArrowDown" ? 1 : -1)))
910
+ ];
911
+ if (next) {
912
+ select(next.span.id);
913
+ const el = document.getElementById(`rv-row-${next.span.id}`);
914
+ el?.scrollIntoView({ block: "nearest" });
915
+ el?.focus();
916
+ }
917
+ } else if ((e.key === "ArrowRight" || e.key === "ArrowLeft") && sel) {
918
+ e.preventDefault();
919
+ setClosed((prev) => {
920
+ const next = new Set(prev);
921
+ if (e.key === "ArrowRight") next.delete(sel);
922
+ else next.add(sel);
923
+ return next;
924
+ });
925
+ }
926
+ };
927
+
928
+ return (
929
+ <div className="rv" style={{ fontFamily: T.sans, color: T.ink }}>
930
+ <style>{`
931
+ .rv *{box-sizing:border-box}
932
+ .rv button{font:inherit}
933
+ .rv pre{margin:0}
934
+ .rv ::selection{background:rgba(14,111,92,.18)}
935
+ .rv .rv-row:hover{background:rgba(23,32,28,.045)}
936
+ .rv :where(button,[role=button]):focus-visible{outline:2px solid ${T.signal};outline-offset:2px;border-radius:4px}
937
+ .rv .rv-cols{display:flex;border:1px solid ${T.line};border-radius:10px;background:${T.panel};overflow:hidden}
938
+ .rv .rv-tree{width:320px;flex-shrink:0;border-right:1px solid ${T.line}}
939
+ @media(max-width:880px){
940
+ .rv .rv-cols{flex-direction:column}
941
+ .rv .rv-tree{width:100%;border-right:none;border-bottom:1px solid ${T.line}}
942
+ }
943
+ @media(prefers-reduced-motion:no-preference){.rv .rv-row{transition:background .12s ease}}
944
+ `}</style>
945
+
946
+ {/* ------------------------------------------------------- header */}
947
+ <Eyebrow>
948
+ run · {run.source?.label ?? run.source?.kind ?? "upload"} ·{" "}
949
+ {created.toISOString().slice(0, 10)}
950
+ {run.group?.item ? ` · item ${run.group.item}` : ""}
951
+ {run.source?.fidelity ? ` · ${run.source.fidelity}` : ""}
952
+ </Eyebrow>
953
+ <div
954
+ style={{
955
+ display: "flex",
956
+ flexWrap: "wrap",
957
+ alignItems: "flex-end",
958
+ gap: 20,
959
+ margin: "8px 0 16px",
960
+ }}
961
+ >
962
+ <h1
963
+ style={{
964
+ fontFamily: T.serif,
965
+ fontSize: 28,
966
+ fontWeight: 500,
967
+ margin: 0,
968
+ flex: "1 1 320px",
969
+ letterSpacing: "-0.01em",
970
+ }}
971
+ >
972
+ {run.name ?? "Untitled session"}
973
+ </h1>
974
+ <div style={{ display: "flex", gap: 24 }}>
975
+ {idx.tokensIn != null && (
976
+ <Metric label="tokens in" value={fmtInt(idx.tokensIn)} />
977
+ )}
978
+ {idx.tokensOut != null && (
979
+ <Metric label="tokens out" value={fmtInt(idx.tokensOut)} />
980
+ )}
981
+ {idx.cost != null && <Metric label="cost" value={fmtCost(idx.cost)} />}
982
+ {idx.durMs != null && (
983
+ <Metric label="duration" value={fmtDur(idx.durMs)} />
984
+ )}
985
+ </div>
986
+ </div>
987
+
988
+ {(idx.models.length > 0 ||
989
+ (run.tags?.length ?? 0) > 0 ||
990
+ (run.scores?.length ?? 0) > 0) && (
991
+ <div
992
+ style={{
993
+ display: "flex",
994
+ flexWrap: "wrap",
995
+ gap: 8,
996
+ alignItems: "center",
997
+ marginBottom: 16,
998
+ }}
999
+ >
1000
+ {idx.models.map((m) => (
1001
+ <Chip key={m}>{m}</Chip>
1002
+ ))}
1003
+ {(run.tags ?? []).map((t) => (
1004
+ <span
1005
+ key={t}
1006
+ style={{ fontFamily: T.mono, fontSize: 12, color: T.faint }}
1007
+ >
1008
+ #{t}
1009
+ </span>
1010
+ ))}
1011
+ {(run.scores ?? []).map((s) => (
1012
+ <Chip key={s.name} title={s.comment}>
1013
+ <span style={{ color: T.faint }}>{s.name}</span>
1014
+ <span
1015
+ style={{
1016
+ color:
1017
+ s.value === true
1018
+ ? T.signal
1019
+ : s.value === false
1020
+ ? T.error
1021
+ : T.ink,
1022
+ fontWeight: 600,
1023
+ }}
1024
+ >
1025
+ {s.value === true ? "✓" : s.value === false ? "✕" : String(s.value)}
1026
+ </span>
1027
+ </Chip>
1028
+ ))}
1029
+ </div>
1030
+ )}
1031
+
1032
+ {/* ---------------------------------------------------- two panels */}
1033
+ <div className="rv-cols">
1034
+ <div className="rv-tree">
1035
+ <div
1036
+ style={{
1037
+ display: "flex",
1038
+ justifyContent: "space-between",
1039
+ alignItems: "baseline",
1040
+ padding: "12px 12px 8px",
1041
+ borderBottom: `1px solid ${T.line}`,
1042
+ }}
1043
+ >
1044
+ <Eyebrow>trace</Eyebrow>
1045
+ <span
1046
+ style={{
1047
+ display: "inline-flex",
1048
+ alignItems: "center",
1049
+ gap: 10,
1050
+ fontFamily: T.mono,
1051
+ fontSize: 11,
1052
+ color: T.faint,
1053
+ }}
1054
+ >
1055
+ {typeCounts.map(([type, n]) => (
1056
+ <span
1057
+ key={type}
1058
+ title={`${n} ${type} span${n === 1 ? "" : "s"}`}
1059
+ style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
1060
+ >
1061
+ <span
1062
+ style={{
1063
+ width: 7,
1064
+ height: 7,
1065
+ borderRadius: 2,
1066
+ background: HUES[type] ?? HUES.custom,
1067
+ }}
1068
+ />
1069
+ {n}
1070
+ </span>
1071
+ ))}
1072
+ {errorCount > 0 && (
1073
+ <span style={{ color: T.error, fontWeight: 600 }} title={`${errorCount} error span${errorCount === 1 ? "" : "s"}`}>
1074
+ {errorCount} err
1075
+ </span>
1076
+ )}
1077
+ </span>
1078
+ </div>
1079
+ {/* NOTE: virtualize this list (e.g. react-virtuoso) before real
1080
+ agent traces arrive — thousands of rows are normal. */}
1081
+ <div
1082
+ onKeyDown={onTreeKey}
1083
+ style={{ padding: "6px 0", overflowY: "auto", maxHeight: "min(72vh, 760px)" }}
1084
+ >
1085
+ {rows.map(({ span, depth }) => (
1086
+ <TreeRow
1087
+ key={span.id}
1088
+ span={span}
1089
+ depth={depth}
1090
+ idx={idx}
1091
+ selected={sel === span.id}
1092
+ hasKids={(idx.children.get(span.id) ?? []).length > 0}
1093
+ open={!closed.has(span.id)}
1094
+ onToggle={() =>
1095
+ setClosed((prev) => {
1096
+ const next = new Set(prev);
1097
+ if (next.has(span.id)) next.delete(span.id);
1098
+ else next.add(span.id);
1099
+ return next;
1100
+ })
1101
+ }
1102
+ onSelect={() => select(span.id)}
1103
+ />
1104
+ ))}
1105
+ </div>
1106
+ </div>
1107
+
1108
+ <div style={{ flex: 1, minWidth: 0, padding: "16px 20px 24px" }}>
1109
+ {selected ? (
1110
+ <>
1111
+ <div
1112
+ style={{
1113
+ display: "flex",
1114
+ flexWrap: "wrap",
1115
+ alignItems: "center",
1116
+ gap: 10,
1117
+ paddingBottom: 12,
1118
+ borderBottom: `1px solid ${T.line}`,
1119
+ marginBottom: 14,
1120
+ }}
1121
+ >
1122
+ <span
1123
+ style={{
1124
+ width: 9,
1125
+ height: 9,
1126
+ borderRadius: 2,
1127
+ background: spanHue(selected),
1128
+ }}
1129
+ />
1130
+ <span style={{ fontSize: 15, fontWeight: 600 }}>
1131
+ {spanLabel(selected)}
1132
+ </span>
1133
+ <span
1134
+ style={{ fontFamily: T.mono, fontSize: 11, color: T.faint }}
1135
+ >
1136
+ {selected.type}
1137
+ </span>
1138
+ {Number.isFinite(spanDur(selected)) && (
1139
+ <span
1140
+ title={
1141
+ selected.started_at
1142
+ ? `${new Date(selected.started_at).toLocaleTimeString()} → ${selected.ended_at ? new Date(selected.ended_at).toLocaleTimeString() : "…"}`
1143
+ : undefined
1144
+ }
1145
+ style={{ fontFamily: T.mono, fontSize: 11, color: T.faint }}
1146
+ >
1147
+ · {fmtDur(spanDur(selected))}
1148
+ </span>
1149
+ )}
1150
+ {selected.fidelity && (
1151
+ <span
1152
+ title="Capture fidelity for this span"
1153
+ style={{
1154
+ fontFamily: T.mono,
1155
+ fontSize: 10,
1156
+ letterSpacing: "0.08em",
1157
+ textTransform: "uppercase",
1158
+ color: T.faint,
1159
+ border: `1px solid ${T.line}`,
1160
+ borderRadius: 999,
1161
+ padding: "2px 8px",
1162
+ }}
1163
+ >
1164
+ {selected.fidelity}
1165
+ </span>
1166
+ )}
1167
+ <span style={{ flex: 1 }} />
1168
+ <button
1169
+ onClick={copyLink}
1170
+ aria-label="Copy link to this span"
1171
+ style={{
1172
+ display: "inline-flex",
1173
+ alignItems: "center",
1174
+ gap: 5,
1175
+ border: `1px solid ${T.line}`,
1176
+ background: T.panel,
1177
+ borderRadius: 6,
1178
+ padding: "4px 9px",
1179
+ fontFamily: T.mono,
1180
+ fontSize: 11,
1181
+ cursor: "pointer",
1182
+ color: copied ? T.signal : T.ink,
1183
+ }}
1184
+ >
1185
+ {copied ? <Check size={12} /> : <Copy size={12} />}
1186
+ {copied ? "copied" : "copy link"}
1187
+ </button>
1188
+ <div
1189
+ role="group"
1190
+ aria-label="View mode"
1191
+ style={{
1192
+ display: "inline-flex",
1193
+ border: `1px solid ${T.line}`,
1194
+ borderRadius: 6,
1195
+ overflow: "hidden",
1196
+ }}
1197
+ >
1198
+ {(["formatted", "raw"] as const).map((mode) => (
1199
+ <button
1200
+ key={mode}
1201
+ onClick={() => setRaw(mode === "raw")}
1202
+ aria-pressed={raw === (mode === "raw")}
1203
+ style={{
1204
+ border: "none",
1205
+ padding: "4px 10px",
1206
+ fontFamily: T.mono,
1207
+ fontSize: 11,
1208
+ cursor: "pointer",
1209
+ background:
1210
+ raw === (mode === "raw") ? T.ink : T.panel,
1211
+ color: raw === (mode === "raw") ? T.paper : T.faint,
1212
+ }}
1213
+ >
1214
+ {mode}
1215
+ </button>
1216
+ ))}
1217
+ </div>
1218
+ </div>
1219
+
1220
+ {selected.status === "error" && selected.error && (
1221
+ <div
1222
+ style={{
1223
+ border: `1px solid ${T.error}`,
1224
+ background: "rgba(179,64,46,0.06)",
1225
+ color: T.error,
1226
+ borderRadius: 6,
1227
+ padding: "8px 12px",
1228
+ fontFamily: T.mono,
1229
+ fontSize: 12,
1230
+ marginBottom: 14,
1231
+ }}
1232
+ >
1233
+ {selected.error.type ? `${selected.error.type}: ` : ""}
1234
+ {selected.error.message}
1235
+ </div>
1236
+ )}
1237
+
1238
+ {raw ? (
1239
+ <JsonBlock value={selected} tall />
1240
+ ) : (
1241
+ <>
1242
+ <SpanDetail span={selected} />
1243
+ {selected.metadata && Object.keys(selected.metadata).length > 0 && (
1244
+ <>
1245
+ <SectionLabel>metadata</SectionLabel>
1246
+ <JsonBlock value={selected.metadata} />
1247
+ </>
1248
+ )}
1249
+ </>
1250
+ )}
1251
+ </>
1252
+ ) : (
1253
+ <div style={{ color: T.faint, fontSize: 13, padding: 24, lineHeight: 2 }}>
1254
+ Select a span to inspect it.
1255
+ <br />
1256
+ <span style={{ fontSize: 12 }}>
1257
+ ↑↓ move through the trace · ←→ collapse and expand
1258
+ </span>
1259
+ </div>
1260
+ )}
1261
+ </div>
1262
+ </div>
1263
+ </div>
1264
+ );
1265
+ }
package/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // @session-link/viewer — the React component that renders a session/v0
2
+ // document. Single-source between local `slink open` and the hosted page,
3
+ // so a preview is byte-for-byte what a recipient sees.
4
+ export { RunViewer } from "./RunViewer";
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@session-link/viewer",
3
+ "version": "0.1.0",
4
+ "description": "The session.link viewer — renders a session/v0 document as an interactive trace tree. React component; consume with a bundler that transpiles the package (e.g. Next transpilePackages).",
5
+ "homepage": "https://session.link",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./index.ts",
9
+ "types": "./index.ts",
10
+ "exports": {
11
+ ".": "./index.ts",
12
+ "./entry": "./viewer-entry.tsx"
13
+ },
14
+ "files": [
15
+ "index.ts",
16
+ "RunViewer.tsx",
17
+ "viewer-entry.tsx"
18
+ ],
19
+ "dependencies": {
20
+ "@session-link/format": "0.1.0"
21
+ },
22
+ "peerDependencies": {
23
+ "lucide-react": "^1.23.0",
24
+ "react": "^19.0.0",
25
+ "react-dom": "^19.0.0"
26
+ }
27
+ }
@@ -0,0 +1,16 @@
1
+ // Entry for the standalone viewer bundle (scripts/build-viewer.mjs).
2
+ // The local `slink open` server embeds a run as window.__RUN__ and this
3
+ // mounts the exact component the hosted site renders — what you preview
4
+ // locally is what the recipient sees.
5
+ import { createRoot } from "react-dom/client";
6
+ import { RunViewer } from "./RunViewer";
7
+ import type { Run } from "@session-link/format";
8
+
9
+ declare global {
10
+ interface Window {
11
+ __RUN__: Run;
12
+ }
13
+ }
14
+
15
+ const el = document.getElementById("root");
16
+ if (el) createRoot(el).render(<RunViewer run={window.__RUN__} />);