@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.
- package/RunViewer.tsx +413 -181
- 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":
|
|
@@ -711,7 +862,9 @@ function TreeRow({
|
|
|
711
862
|
<div
|
|
712
863
|
id={`rv-row-${span.id}`}
|
|
713
864
|
role="button"
|
|
714
|
-
|
|
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
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
}, [
|
|
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
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1035
|
-
|
|
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
|
-
<
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|