@session-link/viewer 0.2.1 → 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 +214 -44
  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":
@@ -844,6 +995,8 @@ function TreeRow({
844
995
  * sessions so the run's bytes travel once, gzipped, instead of
845
996
  * being serialized into both the SSR markup and the hydration
846
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.
847
1000
  */
848
1001
  export function RunViewer({ run, src }: { run?: Run; src?: string }) {
849
1002
  const [fetched, setFetched] = useState<Run | null>(null);
@@ -905,7 +1058,13 @@ export function RunViewer({ run, src }: { run?: Run; src?: string }) {
905
1058
  </div>
906
1059
  );
907
1060
  }
908
- return <LoadedViewer run={resolved} />;
1061
+ return (
1062
+ <Boundary
1063
+ fallback={(message) => <ViewerFallback message={message} src={src} />}
1064
+ >
1065
+ <LoadedViewer run={resolved} />
1066
+ </Boundary>
1067
+ );
909
1068
  }
910
1069
 
911
1070
  /** Every visible tree row is exactly this tall — the invariant the
@@ -1312,7 +1471,7 @@ function LoadedViewer({ run }: { run: Run }) {
1312
1471
  <span
1313
1472
  style={{ fontFamily: T.mono, fontSize: 11, color: T.faint }}
1314
1473
  >
1315
- {selected.type}
1474
+ {String(selected.type)}
1316
1475
  </span>
1317
1476
  {Number.isFinite(spanDur(selected)) && (
1318
1477
  <span
@@ -1340,7 +1499,7 @@ function LoadedViewer({ run }: { run: Run }) {
1340
1499
  padding: "2px 8px",
1341
1500
  }}
1342
1501
  >
1343
- {selected.fidelity}
1502
+ {String(selected.fidelity)}
1344
1503
  </span>
1345
1504
  )}
1346
1505
  <span style={{ flex: 1 }} />
@@ -1410,14 +1569,25 @@ function LoadedViewer({ run }: { run: Run }) {
1410
1569
  }}
1411
1570
  >
1412
1571
  {selected.error.type ? `${selected.error.type}: ` : ""}
1413
- {selected.error.message}
1572
+ {String(selected.error.message ?? "")}
1414
1573
  </div>
1415
1574
  )}
1416
1575
 
1417
1576
  {raw ? (
1418
- <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 />
1419
1582
  ) : (
1420
- <>
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
+ >
1421
1591
  <SpanDetail span={selected} />
1422
1592
  {selected.metadata && Object.keys(selected.metadata).length > 0 && (
1423
1593
  <>
@@ -1425,7 +1595,7 @@ function LoadedViewer({ run }: { run: Run }) {
1425
1595
  <JsonBlock value={selected.metadata} />
1426
1596
  </>
1427
1597
  )}
1428
- </>
1598
+ </Boundary>
1429
1599
  )}
1430
1600
  </>
1431
1601
  ) : (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@session-link/viewer",
3
- "version": "0.2.1",
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",