@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.
- package/RunViewer.tsx +214 -44
- 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
|
-
|
|
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
|
-
|
|
122
|
-
if (
|
|
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'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'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
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
<
|
|
279
|
-
{part.text}
|
|
280
|
-
|
|
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
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|