@session-link/viewer 0.2.0 → 0.3.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.
Files changed (2) hide show
  1. package/RunViewer.tsx +413 -181
  2. package/package.json +1 -1
package/RunViewer.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { Component, useEffect, useMemo, useRef, useState } from "react";
4
4
  import {
5
5
  Bot,
6
6
  Braces,
@@ -60,7 +60,12 @@ const fmtCost = (n: number) => (n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)
60
60
 
61
61
  const spanDur = (s: Span) => ts(s.ended_at) - ts(s.started_at);
62
62
 
63
- const spanLabel = (s: Span) => s.name || s.type;
63
+ // Coerced defensively: the wire format is open, so `name` and `type` can
64
+ // arrive as anything — a non-string label must not throw in every tree row.
65
+ const spanLabel = (s: Span) =>
66
+ (typeof s.name === "string" && s.name) ||
67
+ (typeof s.type === "string" && s.type) ||
68
+ "span";
64
69
 
65
70
  function stringify(v: unknown): string {
66
71
  try {
@@ -118,8 +123,11 @@ function indexRun(run: Run): RunIndex {
118
123
  let sawCost = false;
119
124
  for (const s of run.spans) {
120
125
  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);
126
+ // Open-world guard: a span may claim llm_call without carrying a model.
127
+ if (s.model) {
128
+ const label = `${s.model.provider ? s.model.provider + "/" : ""}${s.model.id}`;
129
+ if (!models.includes(label)) models.push(label);
130
+ }
123
131
  if (s.usage) {
124
132
  sawUsage = true;
125
133
  tin += s.usage.input_tokens ?? 0;
@@ -145,6 +153,84 @@ function indexRun(run: Run): RunIndex {
145
153
  };
146
154
  }
147
155
 
156
+ /* ------------------------------------------------------------ boundaries */
157
+
158
+ /**
159
+ * The format is open-world, so partial or odd documents are normal — data
160
+ * this build can't render must degrade to a card, never a white screen.
161
+ */
162
+ class Boundary extends Component<
163
+ { fallback: (message: string) => React.ReactNode; children: React.ReactNode },
164
+ { message: string | null }
165
+ > {
166
+ state: { message: string | null } = { message: null };
167
+ static getDerivedStateFromError(e: unknown) {
168
+ return { message: e instanceof Error ? e.message : String(e) };
169
+ }
170
+ render() {
171
+ if (this.state.message != null) return this.props.fallback(this.state.message);
172
+ return this.props.children;
173
+ }
174
+ }
175
+
176
+ /** Whole-viewer fallback — the calm version of a crash. */
177
+ function ViewerFallback({ message, src }: { message: string; src?: string }) {
178
+ return (
179
+ <div
180
+ className="rv"
181
+ style={{
182
+ fontFamily: T.mono,
183
+ fontSize: 12,
184
+ color: T.faint,
185
+ border: `1px solid ${T.line}`,
186
+ borderRadius: 10,
187
+ background: T.panel,
188
+ padding: "48px 24px",
189
+ textAlign: "center",
190
+ }}
191
+ >
192
+ <div style={{ color: T.error }}>
193
+ this session couldn&apos;t be rendered — {message}
194
+ </div>
195
+ <div style={{ marginTop: 8 }}>
196
+ {src ? (
197
+ <>
198
+ the raw JSON is still valid — only this viewer build choked on it
199
+ {" · "}
200
+ <a href={src} style={{ color: T.ink }}>
201
+ raw JSON
202
+ </a>
203
+ </>
204
+ ) : (
205
+ /* No URL to point at (e.g. the run was inlined into the page) —
206
+ don't promise an escape hatch this card can't render. */
207
+ <>the document itself is intact — only this viewer build choked on it</>
208
+ )}
209
+ </div>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ /** Per-span fallback — replaces one span's detail, never the tree. */
215
+ function SpanFallback({ message }: { message: string }) {
216
+ return (
217
+ <div
218
+ style={{
219
+ border: `1px dashed ${T.line}`,
220
+ borderRadius: 6,
221
+ padding: "10px 12px",
222
+ fontFamily: T.mono,
223
+ fontSize: 12,
224
+ color: T.faint,
225
+ }}
226
+ >
227
+ <span style={{ color: T.error }}>this span couldn&apos;t be rendered</span>
228
+ {" — "}
229
+ {message} · the raw view still works
230
+ </div>
231
+ );
232
+ }
233
+
148
234
  /* ---------------------------------------------------------------- atoms */
149
235
 
150
236
  function Eyebrow({ children }: { children: React.ReactNode }) {
@@ -249,23 +335,76 @@ function BlobRefNote({ label, hash }: { label: string; hash: string }) {
249
335
  );
250
336
  }
251
337
 
338
+ /** Past either bound, text mounts clamped behind an expander — one huge
339
+ * tool result must not swamp the page (or the DOM) it lands on. */
340
+ const CLAMP_LINES = 20;
341
+ const CLAMP_CHARS = 4 * 1024;
342
+
343
+ function ClampedText({
344
+ text,
345
+ render,
346
+ }: {
347
+ text: string;
348
+ render: (visible: string) => React.ReactNode;
349
+ }) {
350
+ const [showAll, setShowAll] = useState(false);
351
+ const lines = text.split("\n").length;
352
+ if (lines <= CLAMP_LINES && text.length <= CLAMP_CHARS) {
353
+ return <>{render(text)}</>;
354
+ }
355
+ const clipped =
356
+ text.slice(0, CLAMP_CHARS).split("\n").slice(0, CLAMP_LINES).join("\n") +
357
+ "\n…";
358
+ const kb = `${(text.length / 1024).toFixed(1)} KB`;
359
+ return (
360
+ <div style={{ display: "grid", gap: 6 }}>
361
+ {render(showAll ? text : clipped)}
362
+ <button
363
+ onClick={() => setShowAll((v) => !v)}
364
+ style={{
365
+ justifySelf: "start",
366
+ border: `1px solid ${T.line}`,
367
+ background: T.panel,
368
+ borderRadius: 6,
369
+ padding: "4px 9px",
370
+ fontFamily: T.mono,
371
+ fontSize: 11,
372
+ cursor: "pointer",
373
+ color: T.faint,
374
+ }}
375
+ >
376
+ {showAll
377
+ ? "collapse"
378
+ : lines > 1
379
+ ? `show all ${fmtInt(lines)} lines (${kb})`
380
+ : `show all ${kb}`}
381
+ </button>
382
+ </div>
383
+ );
384
+ }
385
+
252
386
  function JsonBlock({ value, tall = false }: { value: unknown; tall?: boolean }) {
253
387
  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>
388
+ <ClampedText
389
+ text={stringify(value)}
390
+ render={(visible) => (
391
+ <pre
392
+ style={{
393
+ fontFamily: T.mono,
394
+ fontSize: 12,
395
+ lineHeight: 1.55,
396
+ background: T.soft,
397
+ borderRadius: 6,
398
+ padding: "10px 12px",
399
+ overflow: "auto",
400
+ maxHeight: tall ? 560 : 280,
401
+ whiteSpace: "pre",
402
+ }}
403
+ >
404
+ {visible}
405
+ </pre>
406
+ )}
407
+ />
269
408
  );
270
409
  }
271
410
 
@@ -275,9 +414,16 @@ function PartView({ part }: { part: ContentPart }) {
275
414
  switch (part.type) {
276
415
  case "text":
277
416
  return (
278
- <div style={{ fontSize: 13.5, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>
279
- {part.text}
280
- </div>
417
+ <ClampedText
418
+ text={part.text}
419
+ render={(visible) => (
420
+ <div
421
+ style={{ fontSize: 13.5, lineHeight: 1.6, whiteSpace: "pre-wrap" }}
422
+ >
423
+ {visible}
424
+ </div>
425
+ )}
426
+ />
281
427
  );
282
428
  case "thinking":
283
429
  return (
@@ -294,21 +440,26 @@ function PartView({ part }: { part: ContentPart }) {
294
440
  >
295
441
  thinking
296
442
  </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>
443
+ <ClampedText
444
+ text={part.text}
445
+ render={(visible) => (
446
+ <div
447
+ style={{
448
+ fontFamily: T.serif,
449
+ fontStyle: "italic",
450
+ fontSize: 13.5,
451
+ lineHeight: 1.6,
452
+ color: T.faint,
453
+ whiteSpace: "pre-wrap",
454
+ borderLeft: `2px solid ${T.line}`,
455
+ padding: "4px 0 4px 12px",
456
+ margin: "8px 0 0",
457
+ }}
458
+ >
459
+ {visible}
460
+ </div>
461
+ )}
462
+ />
312
463
  </details>
313
464
  );
314
465
  case "tool_call":
@@ -711,7 +862,9 @@ function TreeRow({
711
862
  <div
712
863
  id={`rv-row-${span.id}`}
713
864
  role="button"
714
- tabIndex={0}
865
+ // Not a tab stop: the scroll container is the single keyboard surface,
866
+ // so focus can never strand on a row that scrolls out of the window.
867
+ tabIndex={-1}
715
868
  title={
716
869
  span.started_at
717
870
  ? `${spanLabel(span)} · ${new Date(span.started_at).toLocaleTimeString()}${Number.isFinite(dur) ? ` · ${fmtDur(dur)}` : ""}`
@@ -740,6 +893,9 @@ function TreeRow({
740
893
  {hasKids ? (
741
894
  <button
742
895
  aria-label={open ? "Collapse" : "Expand"}
896
+ // Not a tab stop — a focused chevron unmounting with its windowed
897
+ // row would strand focus; the scroll container is the tab stop.
898
+ tabIndex={-1}
743
899
  onClick={(e) => {
744
900
  e.stopPropagation();
745
901
  onToggle();
@@ -839,6 +995,8 @@ function TreeRow({
839
995
  * sessions so the run's bytes travel once, gzipped, instead of
840
996
  * being serialized into both the SSR markup and the hydration
841
997
  * payload (an 11.8MB run made a 17.6MB page that way).
998
+ * Passing both is meaningful: `run` wins for rendering (no fetch happens),
999
+ * and `src` gives the crash fallback a raw-JSON link worth showing.
842
1000
  */
843
1001
  export function RunViewer({ run, src }: { run?: Run; src?: string }) {
844
1002
  const [fetched, setFetched] = useState<Run | null>(null);
@@ -846,6 +1004,9 @@ export function RunViewer({ run, src }: { run?: Run; src?: string }) {
846
1004
 
847
1005
  useEffect(() => {
848
1006
  if (run || !src) return;
1007
+ // A changed src must never show the previous run or a stale error.
1008
+ setFetched(null);
1009
+ setLoadError(null);
849
1010
  let alive = true;
850
1011
  fetch(src)
851
1012
  .then(async (r) => {
@@ -897,21 +1058,41 @@ export function RunViewer({ run, src }: { run?: Run; src?: string }) {
897
1058
  </div>
898
1059
  );
899
1060
  }
900
- return <LoadedViewer run={resolved} />;
1061
+ return (
1062
+ <Boundary
1063
+ fallback={(message) => <ViewerFallback message={message} src={src} />}
1064
+ >
1065
+ <LoadedViewer run={resolved} />
1066
+ </Boundary>
1067
+ );
901
1068
  }
902
1069
 
903
1070
  /** Every visible tree row is exactly this tall — the invariant the
904
1071
  * windowing math depends on, enforced by an explicit height on the row. */
905
1072
  const ROW_H = 30;
906
1073
  const OVERSCAN = 12;
1074
+ /** The list's cosmetic top padding — rows sit at PAD_TOP + i*ROW_H in
1075
+ * content coordinates, so every scroll computation must include it. */
1076
+ const PAD_TOP = 6;
907
1077
 
908
- function LoadedViewer({ run }: { run: Run }) {
909
- const idx = useMemo(() => indexRun(run), [run]);
910
- const [sel, setSel] = useState<string | null>(idx.roots[0]?.id ?? null);
911
- const [closed, setClosed] = useState<Set<string>>(new Set());
912
- const [raw, setRaw] = useState(false);
913
- const [copied, setCopied] = useState(false);
914
-
1078
+ /**
1079
+ * The left column: header, counts, and the windowed span list. Scroll state
1080
+ * lives HERE on purpose a scroll frame re-renders these ~40 rows and
1081
+ * never the span-detail panel next door.
1082
+ */
1083
+ function TreeColumn({
1084
+ idx,
1085
+ sel,
1086
+ closed,
1087
+ onSelect,
1088
+ onSetClosed,
1089
+ }: {
1090
+ idx: RunIndex;
1091
+ sel: string | null;
1092
+ closed: Set<string>;
1093
+ onSelect: (id: string) => void;
1094
+ onSetClosed: React.Dispatch<React.SetStateAction<Set<string>>>;
1095
+ }) {
915
1096
  /* The visible tree, flattened. Thousands of rows are normal for real
916
1097
  agent traces, so only the window around the scroll position mounts —
917
1098
  this array is cheap (plain objects), the DOM is what's rationed. */
@@ -945,65 +1126,35 @@ function LoadedViewer({ run }: { run: Run }) {
945
1126
  const ensureVisible = (i: number) => {
946
1127
  const el = treeRef.current;
947
1128
  if (!el || i < 0) return;
948
- const top = i * ROW_H;
1129
+ const top = PAD_TOP + i * ROW_H;
949
1130
  if (top < el.scrollTop) el.scrollTop = top;
950
1131
  else if (top + ROW_H > el.scrollTop + el.clientHeight)
951
1132
  el.scrollTop = top + ROW_H - el.clientHeight;
952
1133
  };
953
1134
 
954
- /* deep link: read #span=<id> on mount, write it on select */
1135
+ /* Scroll to the selection only when the SELECTION changes (deep link,
1136
+ keyboard, click) — never when rows change identity, or expanding a
1137
+ chevron 800 rows away from the selection would yank the viewport back
1138
+ to it. No-op when already visible or hidden under a collapsed parent. */
1139
+ const lastSel = useRef<string | null>(null);
955
1140
  useEffect(() => {
956
- try {
957
- const m = window.location.hash.match(/span=([\w.-]+)/);
958
- if (m && idx.byId.has(m[1])) {
959
- setSel(m[1]);
960
- ensureVisible(rows.findIndex((r) => r.span.id === m[1]));
961
- }
962
- } catch {
963
- /* sandboxed embeds may not expose location — fine */
964
- }
1141
+ if (sel === lastSel.current) return;
1142
+ lastSel.current = sel;
1143
+ ensureVisible(rows.findIndex((r) => r.span.id === sel));
965
1144
  // eslint-disable-next-line react-hooks/exhaustive-deps
966
- }, [idx]);
967
-
968
- const select = (id: string) => {
969
- setSel(id);
970
- setCopied(false);
971
- try {
972
- history.replaceState(null, "", `#span=${id}`);
973
- } catch {
974
- /* ignore in sandboxes */
975
- }
976
- };
977
-
978
- const copyLink = () => {
979
- if (!sel) return;
980
- let text = `#span=${sel}`;
981
- try {
982
- const u = new URL(window.location.href);
983
- u.hash = `span=${sel}`;
984
- text = u.toString();
985
- } catch {
986
- /* keep relative anchor */
987
- }
988
- navigator.clipboard?.writeText(text).catch(() => {});
989
- setCopied(true);
990
- window.setTimeout(() => setCopied(false), 1400);
991
- };
992
-
993
- const selected = sel ? idx.byId.get(sel) : undefined;
994
- const created = new Date(run.created_at);
1145
+ }, [sel, rows]);
995
1146
 
996
1147
  const { typeCounts, errorCount } = useMemo(() => {
997
1148
  const counts: Array<[string, number]> = [];
998
1149
  let errors = 0;
999
- for (const s of run.spans) {
1150
+ for (const s of idx.byId.values()) {
1000
1151
  if (s.status === "error") errors++;
1001
1152
  const entry = counts.find(([t]) => t === s.type);
1002
1153
  if (entry) entry[1]++;
1003
1154
  else counts.push([s.type, 1]);
1004
1155
  }
1005
1156
  return { typeCounts: counts, errorCount: errors };
1006
- }, [run]);
1157
+ }, [idx]);
1007
1158
 
1008
1159
  /* keyboard: ↑/↓ move through visible rows, ←/→ collapse/expand. The
1009
1160
  handler lives on the scroll container (which is focusable — a focused
@@ -1012,16 +1163,18 @@ function LoadedViewer({ run }: { run: Run }) {
1012
1163
  if (e.key === "ArrowDown" || e.key === "ArrowUp") {
1013
1164
  e.preventDefault();
1014
1165
  const at = rows.findIndex((r) => r.span.id === sel);
1166
+ // Selection hidden under a collapsed ancestor: don't teleport to row 0.
1167
+ if (at === -1) return;
1015
1168
  const ni = Math.min(rows.length - 1, Math.max(0, at + (e.key === "ArrowDown" ? 1 : -1)));
1016
1169
  const next = rows[ni];
1017
1170
  if (next) {
1018
- select(next.span.id);
1171
+ onSelect(next.span.id);
1019
1172
  ensureVisible(ni);
1020
- treeRef.current?.focus();
1173
+ treeRef.current?.focus({ preventScroll: true });
1021
1174
  }
1022
1175
  } else if ((e.key === "ArrowRight" || e.key === "ArrowLeft") && sel) {
1023
1176
  e.preventDefault();
1024
- setClosed((prev) => {
1177
+ onSetClosed((prev) => {
1025
1178
  const next = new Set(prev);
1026
1179
  if (e.key === "ArrowRight") next.delete(sel);
1027
1180
  else next.add(sel);
@@ -1030,9 +1183,151 @@ function LoadedViewer({ run }: { run: Run }) {
1030
1183
  }
1031
1184
  };
1032
1185
 
1033
- /* the window: which slice of rows is actually in the DOM */
1034
- const first = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN);
1035
- const last = Math.min(rows.length, Math.ceil((scrollTop + viewH) / ROW_H) + OVERSCAN);
1186
+ /* the window: which slice of rows is actually in the DOM. scrollTop is
1187
+ clamped so rows shrinking under a deep scroll (collapse-all) can't
1188
+ produce an empty slice while the browser catches up. */
1189
+ const top = Math.min(scrollTop, Math.max(0, rows.length * ROW_H - viewH));
1190
+ const first = Math.max(0, Math.floor((top - PAD_TOP) / ROW_H) - OVERSCAN);
1191
+ const last = Math.min(rows.length, Math.ceil((top - PAD_TOP + viewH) / ROW_H) + OVERSCAN);
1192
+
1193
+ return (
1194
+ <div className="rv-tree">
1195
+ <div
1196
+ style={{
1197
+ display: "flex",
1198
+ justifyContent: "space-between",
1199
+ alignItems: "baseline",
1200
+ padding: "12px 12px 8px",
1201
+ borderBottom: `1px solid ${T.line}`,
1202
+ }}
1203
+ >
1204
+ <Eyebrow>trace</Eyebrow>
1205
+ <span
1206
+ style={{
1207
+ display: "inline-flex",
1208
+ alignItems: "center",
1209
+ gap: 10,
1210
+ fontFamily: T.mono,
1211
+ fontSize: 11,
1212
+ color: T.faint,
1213
+ }}
1214
+ >
1215
+ {typeCounts.map(([type, n]) => (
1216
+ <span
1217
+ key={type}
1218
+ title={`${n} ${type} span${n === 1 ? "" : "s"}`}
1219
+ style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
1220
+ >
1221
+ <span
1222
+ style={{
1223
+ width: 7,
1224
+ height: 7,
1225
+ borderRadius: 2,
1226
+ background: HUES[type] ?? HUES.custom,
1227
+ }}
1228
+ />
1229
+ {n}
1230
+ </span>
1231
+ ))}
1232
+ {errorCount > 0 && (
1233
+ <span style={{ color: T.error, fontWeight: 600 }} title={`${errorCount} error span${errorCount === 1 ? "" : "s"}`}>
1234
+ {errorCount} err
1235
+ </span>
1236
+ )}
1237
+ </span>
1238
+ </div>
1239
+ {/* Windowed: rows are fixed-height, so visibility is arithmetic.
1240
+ A spacer keeps the scrollbar honest; only [first, last) mount. */}
1241
+ <div
1242
+ ref={treeRef}
1243
+ tabIndex={0}
1244
+ aria-label="Trace spans"
1245
+ onKeyDown={onTreeKey}
1246
+ onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
1247
+ style={{ padding: `${PAD_TOP}px 0`, overflowY: "auto", maxHeight: "min(72vh, 760px)" }}
1248
+ >
1249
+ <div style={{ height: rows.length * ROW_H, position: "relative" }}>
1250
+ <div style={{ position: "absolute", top: first * ROW_H, left: 0, right: 0 }}>
1251
+ {rows.slice(first, last).map(({ span, depth }) => (
1252
+ <TreeRow
1253
+ key={span.id}
1254
+ span={span}
1255
+ depth={depth}
1256
+ idx={idx}
1257
+ selected={sel === span.id}
1258
+ hasKids={(idx.children.get(span.id) ?? []).length > 0}
1259
+ open={!closed.has(span.id)}
1260
+ onToggle={() => {
1261
+ onSetClosed((prev) => {
1262
+ const next = new Set(prev);
1263
+ if (next.has(span.id)) next.delete(span.id);
1264
+ else next.add(span.id);
1265
+ return next;
1266
+ });
1267
+ // Keep focus on the keyboard surface: a click focuses the
1268
+ // row/chevron, which may unmount when it scrolls out of
1269
+ // the window — stranding focus on <body> and killing
1270
+ // arrow-key nav.
1271
+ treeRef.current?.focus({ preventScroll: true });
1272
+ }}
1273
+ onSelect={() => {
1274
+ onSelect(span.id);
1275
+ treeRef.current?.focus({ preventScroll: true });
1276
+ }}
1277
+ />
1278
+ ))}
1279
+ </div>
1280
+ </div>
1281
+ </div>
1282
+ </div>
1283
+ );
1284
+ }
1285
+
1286
+ function LoadedViewer({ run }: { run: Run }) {
1287
+ const idx = useMemo(() => indexRun(run), [run]);
1288
+ const [sel, setSel] = useState<string | null>(idx.roots[0]?.id ?? null);
1289
+ const [closed, setClosed] = useState<Set<string>>(new Set());
1290
+ const [raw, setRaw] = useState(false);
1291
+ const [copied, setCopied] = useState(false);
1292
+
1293
+ /* deep link: read #span=<id> on mount, write it on select. TreeColumn's
1294
+ selection effect scrolls it into the window once it renders. */
1295
+ useEffect(() => {
1296
+ try {
1297
+ const m = window.location.hash.match(/span=([\w.-]+)/);
1298
+ if (m && idx.byId.has(m[1])) setSel(m[1]);
1299
+ } catch {
1300
+ /* sandboxed embeds may not expose location — fine */
1301
+ }
1302
+ }, [idx]);
1303
+
1304
+ const select = (id: string) => {
1305
+ setSel(id);
1306
+ setCopied(false);
1307
+ try {
1308
+ history.replaceState(null, "", `#span=${id}`);
1309
+ } catch {
1310
+ /* ignore in sandboxes */
1311
+ }
1312
+ };
1313
+
1314
+ const copyLink = () => {
1315
+ if (!sel) return;
1316
+ let text = `#span=${sel}`;
1317
+ try {
1318
+ const u = new URL(window.location.href);
1319
+ u.hash = `span=${sel}`;
1320
+ text = u.toString();
1321
+ } catch {
1322
+ /* keep relative anchor */
1323
+ }
1324
+ navigator.clipboard?.writeText(text).catch(() => {});
1325
+ setCopied(true);
1326
+ window.setTimeout(() => setCopied(false), 1400);
1327
+ };
1328
+
1329
+ const selected = sel ? idx.byId.get(sel) : undefined;
1330
+ const created = new Date(run.created_at);
1036
1331
 
1037
1332
  return (
1038
1333
  <div className="rv" style={{ fontFamily: T.sans, color: T.ink }}>
@@ -1140,87 +1435,13 @@ function LoadedViewer({ run }: { run: Run }) {
1140
1435
 
1141
1436
  {/* ---------------------------------------------------- two panels */}
1142
1437
  <div className="rv-cols">
1143
- <div className="rv-tree">
1144
- <div
1145
- style={{
1146
- display: "flex",
1147
- justifyContent: "space-between",
1148
- alignItems: "baseline",
1149
- padding: "12px 12px 8px",
1150
- borderBottom: `1px solid ${T.line}`,
1151
- }}
1152
- >
1153
- <Eyebrow>trace</Eyebrow>
1154
- <span
1155
- style={{
1156
- display: "inline-flex",
1157
- alignItems: "center",
1158
- gap: 10,
1159
- fontFamily: T.mono,
1160
- fontSize: 11,
1161
- color: T.faint,
1162
- }}
1163
- >
1164
- {typeCounts.map(([type, n]) => (
1165
- <span
1166
- key={type}
1167
- title={`${n} ${type} span${n === 1 ? "" : "s"}`}
1168
- style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
1169
- >
1170
- <span
1171
- style={{
1172
- width: 7,
1173
- height: 7,
1174
- borderRadius: 2,
1175
- background: HUES[type] ?? HUES.custom,
1176
- }}
1177
- />
1178
- {n}
1179
- </span>
1180
- ))}
1181
- {errorCount > 0 && (
1182
- <span style={{ color: T.error, fontWeight: 600 }} title={`${errorCount} error span${errorCount === 1 ? "" : "s"}`}>
1183
- {errorCount} err
1184
- </span>
1185
- )}
1186
- </span>
1187
- </div>
1188
- {/* Windowed: rows are fixed-height, so visibility is arithmetic.
1189
- A spacer keeps the scrollbar honest; only [first, last) mount. */}
1190
- <div
1191
- ref={treeRef}
1192
- tabIndex={0}
1193
- aria-label="Trace spans"
1194
- onKeyDown={onTreeKey}
1195
- onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
1196
- style={{ padding: "6px 0", overflowY: "auto", maxHeight: "min(72vh, 760px)" }}
1197
- >
1198
- <div style={{ height: rows.length * ROW_H, position: "relative" }}>
1199
- <div style={{ position: "absolute", top: first * ROW_H, left: 0, right: 0 }}>
1200
- {rows.slice(first, last).map(({ span, depth }) => (
1201
- <TreeRow
1202
- key={span.id}
1203
- span={span}
1204
- depth={depth}
1205
- idx={idx}
1206
- selected={sel === span.id}
1207
- hasKids={(idx.children.get(span.id) ?? []).length > 0}
1208
- open={!closed.has(span.id)}
1209
- onToggle={() =>
1210
- setClosed((prev) => {
1211
- const next = new Set(prev);
1212
- if (next.has(span.id)) next.delete(span.id);
1213
- else next.add(span.id);
1214
- return next;
1215
- })
1216
- }
1217
- onSelect={() => select(span.id)}
1218
- />
1219
- ))}
1220
- </div>
1221
- </div>
1222
- </div>
1223
- </div>
1438
+ <TreeColumn
1439
+ idx={idx}
1440
+ sel={sel}
1441
+ closed={closed}
1442
+ onSelect={select}
1443
+ onSetClosed={setClosed}
1444
+ />
1224
1445
 
1225
1446
  <div style={{ flex: 1, minWidth: 0, padding: "16px 20px 24px" }}>
1226
1447
  {selected ? (
@@ -1250,7 +1471,7 @@ function LoadedViewer({ run }: { run: Run }) {
1250
1471
  <span
1251
1472
  style={{ fontFamily: T.mono, fontSize: 11, color: T.faint }}
1252
1473
  >
1253
- {selected.type}
1474
+ {String(selected.type)}
1254
1475
  </span>
1255
1476
  {Number.isFinite(spanDur(selected)) && (
1256
1477
  <span
@@ -1278,7 +1499,7 @@ function LoadedViewer({ run }: { run: Run }) {
1278
1499
  padding: "2px 8px",
1279
1500
  }}
1280
1501
  >
1281
- {selected.fidelity}
1502
+ {String(selected.fidelity)}
1282
1503
  </span>
1283
1504
  )}
1284
1505
  <span style={{ flex: 1 }} />
@@ -1348,14 +1569,25 @@ function LoadedViewer({ run }: { run: Run }) {
1348
1569
  }}
1349
1570
  >
1350
1571
  {selected.error.type ? `${selected.error.type}: ` : ""}
1351
- {selected.error.message}
1572
+ {String(selected.error.message ?? "")}
1352
1573
  </div>
1353
1574
  )}
1354
1575
 
1355
1576
  {raw ? (
1356
- <JsonBlock value={selected} tall />
1577
+ /* Keyed like the Boundary below: without the key, this
1578
+ JsonBlock reconciles in place across selections and the
1579
+ clamp expander's showAll state leaks — span B would mount
1580
+ with span A's expansion. */
1581
+ <JsonBlock key={sel ?? ""} value={selected} tall />
1357
1582
  ) : (
1358
- <>
1583
+ /* Keyed by span: a new selection resets both a caught error
1584
+ and any expander state left inside the old detail. The raw
1585
+ toggle lives in the header above, so it stays usable when
1586
+ this subtree falls back. */
1587
+ <Boundary
1588
+ key={sel ?? ""}
1589
+ fallback={(message) => <SpanFallback message={message} />}
1590
+ >
1359
1591
  <SpanDetail span={selected} />
1360
1592
  {selected.metadata && Object.keys(selected.metadata).length > 0 && (
1361
1593
  <>
@@ -1363,7 +1595,7 @@ function LoadedViewer({ run }: { run: Run }) {
1363
1595
  <JsonBlock value={selected.metadata} />
1364
1596
  </>
1365
1597
  )}
1366
- </>
1598
+ </Boundary>
1367
1599
  )}
1368
1600
  </>
1369
1601
  ) : (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@session-link/viewer",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
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
5
  "homepage": "https://session.link",
6
6
  "license": "MIT",