@session-link/viewer 0.1.0 → 0.2.1
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 +308 -129
- package/package.json +1 -1
package/RunViewer.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import {
|
|
5
5
|
Bot,
|
|
6
6
|
Braces,
|
|
@@ -711,7 +711,9 @@ function TreeRow({
|
|
|
711
711
|
<div
|
|
712
712
|
id={`rv-row-${span.id}`}
|
|
713
713
|
role="button"
|
|
714
|
-
|
|
714
|
+
// Not a tab stop: the scroll container is the single keyboard surface,
|
|
715
|
+
// so focus can never strand on a row that scrolls out of the window.
|
|
716
|
+
tabIndex={-1}
|
|
715
717
|
title={
|
|
716
718
|
span.started_at
|
|
717
719
|
? `${spanLabel(span)} · ${new Date(span.started_at).toLocaleTimeString()}${Number.isFinite(dur) ? ` · ${fmtDur(dur)}` : ""}`
|
|
@@ -729,7 +731,8 @@ function TreeRow({
|
|
|
729
731
|
display: "flex",
|
|
730
732
|
alignItems: "center",
|
|
731
733
|
gap: 6,
|
|
732
|
-
|
|
734
|
+
height: ROW_H,
|
|
735
|
+
padding: "0 12px 0 0",
|
|
733
736
|
paddingLeft: 10 + depth * 14,
|
|
734
737
|
cursor: "pointer",
|
|
735
738
|
background: selected ? "rgba(14,111,92,0.08)" : "transparent",
|
|
@@ -739,6 +742,9 @@ function TreeRow({
|
|
|
739
742
|
{hasKids ? (
|
|
740
743
|
<button
|
|
741
744
|
aria-label={open ? "Collapse" : "Expand"}
|
|
745
|
+
// Not a tab stop — a focused chevron unmounting with its windowed
|
|
746
|
+
// row would strand focus; the scroll container is the tab stop.
|
|
747
|
+
tabIndex={-1}
|
|
742
748
|
onClick={(e) => {
|
|
743
749
|
e.stopPropagation();
|
|
744
750
|
onToggle();
|
|
@@ -830,23 +836,307 @@ function TreeRow({
|
|
|
830
836
|
|
|
831
837
|
/* ------------------------------------------------------------- component */
|
|
832
838
|
|
|
833
|
-
|
|
839
|
+
/**
|
|
840
|
+
* Renders a session/v0 document. Two ways in:
|
|
841
|
+
* run — the parsed document, inlined by the caller (local `slink open`,
|
|
842
|
+
* bundled examples, small sessions).
|
|
843
|
+
* src — a URL to fetch it from. The hosted site uses this for big
|
|
844
|
+
* sessions so the run's bytes travel once, gzipped, instead of
|
|
845
|
+
* being serialized into both the SSR markup and the hydration
|
|
846
|
+
* payload (an 11.8MB run made a 17.6MB page that way).
|
|
847
|
+
*/
|
|
848
|
+
export function RunViewer({ run, src }: { run?: Run; src?: string }) {
|
|
849
|
+
const [fetched, setFetched] = useState<Run | null>(null);
|
|
850
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
851
|
+
|
|
852
|
+
useEffect(() => {
|
|
853
|
+
if (run || !src) return;
|
|
854
|
+
// A changed src must never show the previous run or a stale error.
|
|
855
|
+
setFetched(null);
|
|
856
|
+
setLoadError(null);
|
|
857
|
+
let alive = true;
|
|
858
|
+
fetch(src)
|
|
859
|
+
.then(async (r) => {
|
|
860
|
+
if (!r.ok) throw new Error(`the server answered ${r.status}`);
|
|
861
|
+
return (await r.json()) as Run;
|
|
862
|
+
})
|
|
863
|
+
.then((data) => {
|
|
864
|
+
if (alive) setFetched(data);
|
|
865
|
+
})
|
|
866
|
+
.catch((e: unknown) => {
|
|
867
|
+
if (alive) setLoadError(e instanceof Error ? e.message : String(e));
|
|
868
|
+
});
|
|
869
|
+
return () => {
|
|
870
|
+
alive = false;
|
|
871
|
+
};
|
|
872
|
+
}, [run, src]);
|
|
873
|
+
|
|
874
|
+
const resolved = run ?? fetched;
|
|
875
|
+
if (!resolved) {
|
|
876
|
+
return (
|
|
877
|
+
<div
|
|
878
|
+
className="rv"
|
|
879
|
+
style={{
|
|
880
|
+
fontFamily: T.mono,
|
|
881
|
+
fontSize: 12,
|
|
882
|
+
color: loadError ? T.error : T.faint,
|
|
883
|
+
border: `1px solid ${T.line}`,
|
|
884
|
+
borderRadius: 10,
|
|
885
|
+
background: T.panel,
|
|
886
|
+
padding: "48px 24px",
|
|
887
|
+
textAlign: "center",
|
|
888
|
+
}}
|
|
889
|
+
>
|
|
890
|
+
{loadError ? (
|
|
891
|
+
<>
|
|
892
|
+
couldn't load this session — {loadError}
|
|
893
|
+
{src && (
|
|
894
|
+
<>
|
|
895
|
+
{" · "}
|
|
896
|
+
<a href={src} style={{ color: T.ink }}>
|
|
897
|
+
raw JSON
|
|
898
|
+
</a>
|
|
899
|
+
</>
|
|
900
|
+
)}
|
|
901
|
+
</>
|
|
902
|
+
) : (
|
|
903
|
+
"loading session…"
|
|
904
|
+
)}
|
|
905
|
+
</div>
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
return <LoadedViewer run={resolved} />;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Every visible tree row is exactly this tall — the invariant the
|
|
912
|
+
* windowing math depends on, enforced by an explicit height on the row. */
|
|
913
|
+
const ROW_H = 30;
|
|
914
|
+
const OVERSCAN = 12;
|
|
915
|
+
/** The list's cosmetic top padding — rows sit at PAD_TOP + i*ROW_H in
|
|
916
|
+
* content coordinates, so every scroll computation must include it. */
|
|
917
|
+
const PAD_TOP = 6;
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* The left column: header, counts, and the windowed span list. Scroll state
|
|
921
|
+
* lives HERE on purpose — a scroll frame re-renders these ~40 rows and
|
|
922
|
+
* never the span-detail panel next door.
|
|
923
|
+
*/
|
|
924
|
+
function TreeColumn({
|
|
925
|
+
idx,
|
|
926
|
+
sel,
|
|
927
|
+
closed,
|
|
928
|
+
onSelect,
|
|
929
|
+
onSetClosed,
|
|
930
|
+
}: {
|
|
931
|
+
idx: RunIndex;
|
|
932
|
+
sel: string | null;
|
|
933
|
+
closed: Set<string>;
|
|
934
|
+
onSelect: (id: string) => void;
|
|
935
|
+
onSetClosed: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
936
|
+
}) {
|
|
937
|
+
/* The visible tree, flattened. Thousands of rows are normal for real
|
|
938
|
+
agent traces, so only the window around the scroll position mounts —
|
|
939
|
+
this array is cheap (plain objects), the DOM is what's rationed. */
|
|
940
|
+
const rows = useMemo(() => {
|
|
941
|
+
const out: Array<{ span: Span; depth: number }> = [];
|
|
942
|
+
const walk = (list: Span[], depth: number) => {
|
|
943
|
+
for (const s of list) {
|
|
944
|
+
out.push({ span: s, depth });
|
|
945
|
+
if (!closed.has(s.id)) walk(idx.children.get(s.id) ?? [], depth + 1);
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
walk(idx.roots, 0);
|
|
949
|
+
return out;
|
|
950
|
+
}, [idx, closed]);
|
|
951
|
+
|
|
952
|
+
const treeRef = useRef<HTMLDivElement | null>(null);
|
|
953
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
954
|
+
const [viewH, setViewH] = useState(760);
|
|
955
|
+
useEffect(() => {
|
|
956
|
+
const el = treeRef.current;
|
|
957
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
958
|
+
const update = () => setViewH(el.clientHeight || 760);
|
|
959
|
+
update();
|
|
960
|
+
const ro = new ResizeObserver(update);
|
|
961
|
+
ro.observe(el);
|
|
962
|
+
return () => ro.disconnect();
|
|
963
|
+
}, []);
|
|
964
|
+
|
|
965
|
+
/* Scroll a row index into the window — the windowed replacement for
|
|
966
|
+
scrollIntoView, which can't target a row that isn't mounted. */
|
|
967
|
+
const ensureVisible = (i: number) => {
|
|
968
|
+
const el = treeRef.current;
|
|
969
|
+
if (!el || i < 0) return;
|
|
970
|
+
const top = PAD_TOP + i * ROW_H;
|
|
971
|
+
if (top < el.scrollTop) el.scrollTop = top;
|
|
972
|
+
else if (top + ROW_H > el.scrollTop + el.clientHeight)
|
|
973
|
+
el.scrollTop = top + ROW_H - el.clientHeight;
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
/* Scroll to the selection only when the SELECTION changes (deep link,
|
|
977
|
+
keyboard, click) — never when rows change identity, or expanding a
|
|
978
|
+
chevron 800 rows away from the selection would yank the viewport back
|
|
979
|
+
to it. No-op when already visible or hidden under a collapsed parent. */
|
|
980
|
+
const lastSel = useRef<string | null>(null);
|
|
981
|
+
useEffect(() => {
|
|
982
|
+
if (sel === lastSel.current) return;
|
|
983
|
+
lastSel.current = sel;
|
|
984
|
+
ensureVisible(rows.findIndex((r) => r.span.id === sel));
|
|
985
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
986
|
+
}, [sel, rows]);
|
|
987
|
+
|
|
988
|
+
const { typeCounts, errorCount } = useMemo(() => {
|
|
989
|
+
const counts: Array<[string, number]> = [];
|
|
990
|
+
let errors = 0;
|
|
991
|
+
for (const s of idx.byId.values()) {
|
|
992
|
+
if (s.status === "error") errors++;
|
|
993
|
+
const entry = counts.find(([t]) => t === s.type);
|
|
994
|
+
if (entry) entry[1]++;
|
|
995
|
+
else counts.push([s.type, 1]);
|
|
996
|
+
}
|
|
997
|
+
return { typeCounts: counts, errorCount: errors };
|
|
998
|
+
}, [idx]);
|
|
999
|
+
|
|
1000
|
+
/* keyboard: ↑/↓ move through visible rows, ←/→ collapse/expand. The
|
|
1001
|
+
handler lives on the scroll container (which is focusable — a focused
|
|
1002
|
+
row can unmount when it scrolls out of the window). */
|
|
1003
|
+
const onTreeKey = (e: React.KeyboardEvent) => {
|
|
1004
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
1005
|
+
e.preventDefault();
|
|
1006
|
+
const at = rows.findIndex((r) => r.span.id === sel);
|
|
1007
|
+
// Selection hidden under a collapsed ancestor: don't teleport to row 0.
|
|
1008
|
+
if (at === -1) return;
|
|
1009
|
+
const ni = Math.min(rows.length - 1, Math.max(0, at + (e.key === "ArrowDown" ? 1 : -1)));
|
|
1010
|
+
const next = rows[ni];
|
|
1011
|
+
if (next) {
|
|
1012
|
+
onSelect(next.span.id);
|
|
1013
|
+
ensureVisible(ni);
|
|
1014
|
+
treeRef.current?.focus({ preventScroll: true });
|
|
1015
|
+
}
|
|
1016
|
+
} else if ((e.key === "ArrowRight" || e.key === "ArrowLeft") && sel) {
|
|
1017
|
+
e.preventDefault();
|
|
1018
|
+
onSetClosed((prev) => {
|
|
1019
|
+
const next = new Set(prev);
|
|
1020
|
+
if (e.key === "ArrowRight") next.delete(sel);
|
|
1021
|
+
else next.add(sel);
|
|
1022
|
+
return next;
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
/* the window: which slice of rows is actually in the DOM. scrollTop is
|
|
1028
|
+
clamped so rows shrinking under a deep scroll (collapse-all) can't
|
|
1029
|
+
produce an empty slice while the browser catches up. */
|
|
1030
|
+
const top = Math.min(scrollTop, Math.max(0, rows.length * ROW_H - viewH));
|
|
1031
|
+
const first = Math.max(0, Math.floor((top - PAD_TOP) / ROW_H) - OVERSCAN);
|
|
1032
|
+
const last = Math.min(rows.length, Math.ceil((top - PAD_TOP + viewH) / ROW_H) + OVERSCAN);
|
|
1033
|
+
|
|
1034
|
+
return (
|
|
1035
|
+
<div className="rv-tree">
|
|
1036
|
+
<div
|
|
1037
|
+
style={{
|
|
1038
|
+
display: "flex",
|
|
1039
|
+
justifyContent: "space-between",
|
|
1040
|
+
alignItems: "baseline",
|
|
1041
|
+
padding: "12px 12px 8px",
|
|
1042
|
+
borderBottom: `1px solid ${T.line}`,
|
|
1043
|
+
}}
|
|
1044
|
+
>
|
|
1045
|
+
<Eyebrow>trace</Eyebrow>
|
|
1046
|
+
<span
|
|
1047
|
+
style={{
|
|
1048
|
+
display: "inline-flex",
|
|
1049
|
+
alignItems: "center",
|
|
1050
|
+
gap: 10,
|
|
1051
|
+
fontFamily: T.mono,
|
|
1052
|
+
fontSize: 11,
|
|
1053
|
+
color: T.faint,
|
|
1054
|
+
}}
|
|
1055
|
+
>
|
|
1056
|
+
{typeCounts.map(([type, n]) => (
|
|
1057
|
+
<span
|
|
1058
|
+
key={type}
|
|
1059
|
+
title={`${n} ${type} span${n === 1 ? "" : "s"}`}
|
|
1060
|
+
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
|
1061
|
+
>
|
|
1062
|
+
<span
|
|
1063
|
+
style={{
|
|
1064
|
+
width: 7,
|
|
1065
|
+
height: 7,
|
|
1066
|
+
borderRadius: 2,
|
|
1067
|
+
background: HUES[type] ?? HUES.custom,
|
|
1068
|
+
}}
|
|
1069
|
+
/>
|
|
1070
|
+
{n}
|
|
1071
|
+
</span>
|
|
1072
|
+
))}
|
|
1073
|
+
{errorCount > 0 && (
|
|
1074
|
+
<span style={{ color: T.error, fontWeight: 600 }} title={`${errorCount} error span${errorCount === 1 ? "" : "s"}`}>
|
|
1075
|
+
{errorCount} err
|
|
1076
|
+
</span>
|
|
1077
|
+
)}
|
|
1078
|
+
</span>
|
|
1079
|
+
</div>
|
|
1080
|
+
{/* Windowed: rows are fixed-height, so visibility is arithmetic.
|
|
1081
|
+
A spacer keeps the scrollbar honest; only [first, last) mount. */}
|
|
1082
|
+
<div
|
|
1083
|
+
ref={treeRef}
|
|
1084
|
+
tabIndex={0}
|
|
1085
|
+
aria-label="Trace spans"
|
|
1086
|
+
onKeyDown={onTreeKey}
|
|
1087
|
+
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
|
1088
|
+
style={{ padding: `${PAD_TOP}px 0`, overflowY: "auto", maxHeight: "min(72vh, 760px)" }}
|
|
1089
|
+
>
|
|
1090
|
+
<div style={{ height: rows.length * ROW_H, position: "relative" }}>
|
|
1091
|
+
<div style={{ position: "absolute", top: first * ROW_H, left: 0, right: 0 }}>
|
|
1092
|
+
{rows.slice(first, last).map(({ span, depth }) => (
|
|
1093
|
+
<TreeRow
|
|
1094
|
+
key={span.id}
|
|
1095
|
+
span={span}
|
|
1096
|
+
depth={depth}
|
|
1097
|
+
idx={idx}
|
|
1098
|
+
selected={sel === span.id}
|
|
1099
|
+
hasKids={(idx.children.get(span.id) ?? []).length > 0}
|
|
1100
|
+
open={!closed.has(span.id)}
|
|
1101
|
+
onToggle={() => {
|
|
1102
|
+
onSetClosed((prev) => {
|
|
1103
|
+
const next = new Set(prev);
|
|
1104
|
+
if (next.has(span.id)) next.delete(span.id);
|
|
1105
|
+
else next.add(span.id);
|
|
1106
|
+
return next;
|
|
1107
|
+
});
|
|
1108
|
+
// Keep focus on the keyboard surface: a click focuses the
|
|
1109
|
+
// row/chevron, which may unmount when it scrolls out of
|
|
1110
|
+
// the window — stranding focus on <body> and killing
|
|
1111
|
+
// arrow-key nav.
|
|
1112
|
+
treeRef.current?.focus({ preventScroll: true });
|
|
1113
|
+
}}
|
|
1114
|
+
onSelect={() => {
|
|
1115
|
+
onSelect(span.id);
|
|
1116
|
+
treeRef.current?.focus({ preventScroll: true });
|
|
1117
|
+
}}
|
|
1118
|
+
/>
|
|
1119
|
+
))}
|
|
1120
|
+
</div>
|
|
1121
|
+
</div>
|
|
1122
|
+
</div>
|
|
1123
|
+
</div>
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function LoadedViewer({ run }: { run: Run }) {
|
|
834
1128
|
const idx = useMemo(() => indexRun(run), [run]);
|
|
835
1129
|
const [sel, setSel] = useState<string | null>(idx.roots[0]?.id ?? null);
|
|
836
1130
|
const [closed, setClosed] = useState<Set<string>>(new Set());
|
|
837
1131
|
const [raw, setRaw] = useState(false);
|
|
838
1132
|
const [copied, setCopied] = useState(false);
|
|
839
1133
|
|
|
840
|
-
/* deep link: read #span=<id> on mount, write it on select
|
|
1134
|
+
/* deep link: read #span=<id> on mount, write it on select. TreeColumn's
|
|
1135
|
+
selection effect scrolls it into the window once it renders. */
|
|
841
1136
|
useEffect(() => {
|
|
842
1137
|
try {
|
|
843
1138
|
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
|
-
}
|
|
1139
|
+
if (m && idx.byId.has(m[1])) setSel(m[1]);
|
|
850
1140
|
} catch {
|
|
851
1141
|
/* sandboxed embeds may not expose location — fine */
|
|
852
1142
|
}
|
|
@@ -877,54 +1167,9 @@ export function RunViewer({ run }: { run: Run }) {
|
|
|
877
1167
|
window.setTimeout(() => setCopied(false), 1400);
|
|
878
1168
|
};
|
|
879
1169
|
|
|
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
1170
|
const selected = sel ? idx.byId.get(sel) : undefined;
|
|
890
1171
|
const created = new Date(run.created_at);
|
|
891
1172
|
|
|
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
1173
|
return (
|
|
929
1174
|
<div className="rv" style={{ fontFamily: T.sans, color: T.ink }}>
|
|
930
1175
|
<style>{`
|
|
@@ -1031,79 +1276,13 @@ export function RunViewer({ run }: { run: Run }) {
|
|
|
1031
1276
|
|
|
1032
1277
|
{/* ---------------------------------------------------- two panels */}
|
|
1033
1278
|
<div className="rv-cols">
|
|
1034
|
-
<
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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>
|
|
1279
|
+
<TreeColumn
|
|
1280
|
+
idx={idx}
|
|
1281
|
+
sel={sel}
|
|
1282
|
+
closed={closed}
|
|
1283
|
+
onSelect={select}
|
|
1284
|
+
onSetClosed={setClosed}
|
|
1285
|
+
/>
|
|
1107
1286
|
|
|
1108
1287
|
<div style={{ flex: 1, minWidth: 0, padding: "16px 20px 24px" }}>
|
|
1109
1288
|
{selected ? (
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@session-link/viewer",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|