@session-link/viewer 0.1.0 → 0.2.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 +170 -53
- 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,
|
|
@@ -729,7 +729,8 @@ function TreeRow({
|
|
|
729
729
|
display: "flex",
|
|
730
730
|
alignItems: "center",
|
|
731
731
|
gap: 6,
|
|
732
|
-
|
|
732
|
+
height: ROW_H,
|
|
733
|
+
padding: "0 12px 0 0",
|
|
733
734
|
paddingLeft: 10 + depth * 14,
|
|
734
735
|
cursor: "pointer",
|
|
735
736
|
background: selected ? "rgba(14,111,92,0.08)" : "transparent",
|
|
@@ -830,26 +831,138 @@ function TreeRow({
|
|
|
830
831
|
|
|
831
832
|
/* ------------------------------------------------------------- component */
|
|
832
833
|
|
|
833
|
-
|
|
834
|
+
/**
|
|
835
|
+
* Renders a session/v0 document. Two ways in:
|
|
836
|
+
* run — the parsed document, inlined by the caller (local `slink open`,
|
|
837
|
+
* bundled examples, small sessions).
|
|
838
|
+
* src — a URL to fetch it from. The hosted site uses this for big
|
|
839
|
+
* sessions so the run's bytes travel once, gzipped, instead of
|
|
840
|
+
* being serialized into both the SSR markup and the hydration
|
|
841
|
+
* payload (an 11.8MB run made a 17.6MB page that way).
|
|
842
|
+
*/
|
|
843
|
+
export function RunViewer({ run, src }: { run?: Run; src?: string }) {
|
|
844
|
+
const [fetched, setFetched] = useState<Run | null>(null);
|
|
845
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
846
|
+
|
|
847
|
+
useEffect(() => {
|
|
848
|
+
if (run || !src) return;
|
|
849
|
+
let alive = true;
|
|
850
|
+
fetch(src)
|
|
851
|
+
.then(async (r) => {
|
|
852
|
+
if (!r.ok) throw new Error(`the server answered ${r.status}`);
|
|
853
|
+
return (await r.json()) as Run;
|
|
854
|
+
})
|
|
855
|
+
.then((data) => {
|
|
856
|
+
if (alive) setFetched(data);
|
|
857
|
+
})
|
|
858
|
+
.catch((e: unknown) => {
|
|
859
|
+
if (alive) setLoadError(e instanceof Error ? e.message : String(e));
|
|
860
|
+
});
|
|
861
|
+
return () => {
|
|
862
|
+
alive = false;
|
|
863
|
+
};
|
|
864
|
+
}, [run, src]);
|
|
865
|
+
|
|
866
|
+
const resolved = run ?? fetched;
|
|
867
|
+
if (!resolved) {
|
|
868
|
+
return (
|
|
869
|
+
<div
|
|
870
|
+
className="rv"
|
|
871
|
+
style={{
|
|
872
|
+
fontFamily: T.mono,
|
|
873
|
+
fontSize: 12,
|
|
874
|
+
color: loadError ? T.error : T.faint,
|
|
875
|
+
border: `1px solid ${T.line}`,
|
|
876
|
+
borderRadius: 10,
|
|
877
|
+
background: T.panel,
|
|
878
|
+
padding: "48px 24px",
|
|
879
|
+
textAlign: "center",
|
|
880
|
+
}}
|
|
881
|
+
>
|
|
882
|
+
{loadError ? (
|
|
883
|
+
<>
|
|
884
|
+
couldn't load this session — {loadError}
|
|
885
|
+
{src && (
|
|
886
|
+
<>
|
|
887
|
+
{" · "}
|
|
888
|
+
<a href={src} style={{ color: T.ink }}>
|
|
889
|
+
raw JSON
|
|
890
|
+
</a>
|
|
891
|
+
</>
|
|
892
|
+
)}
|
|
893
|
+
</>
|
|
894
|
+
) : (
|
|
895
|
+
"loading session…"
|
|
896
|
+
)}
|
|
897
|
+
</div>
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
return <LoadedViewer run={resolved} />;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Every visible tree row is exactly this tall — the invariant the
|
|
904
|
+
* windowing math depends on, enforced by an explicit height on the row. */
|
|
905
|
+
const ROW_H = 30;
|
|
906
|
+
const OVERSCAN = 12;
|
|
907
|
+
|
|
908
|
+
function LoadedViewer({ run }: { run: Run }) {
|
|
834
909
|
const idx = useMemo(() => indexRun(run), [run]);
|
|
835
910
|
const [sel, setSel] = useState<string | null>(idx.roots[0]?.id ?? null);
|
|
836
911
|
const [closed, setClosed] = useState<Set<string>>(new Set());
|
|
837
912
|
const [raw, setRaw] = useState(false);
|
|
838
913
|
const [copied, setCopied] = useState(false);
|
|
839
914
|
|
|
915
|
+
/* The visible tree, flattened. Thousands of rows are normal for real
|
|
916
|
+
agent traces, so only the window around the scroll position mounts —
|
|
917
|
+
this array is cheap (plain objects), the DOM is what's rationed. */
|
|
918
|
+
const rows = useMemo(() => {
|
|
919
|
+
const out: Array<{ span: Span; depth: number }> = [];
|
|
920
|
+
const walk = (list: Span[], depth: number) => {
|
|
921
|
+
for (const s of list) {
|
|
922
|
+
out.push({ span: s, depth });
|
|
923
|
+
if (!closed.has(s.id)) walk(idx.children.get(s.id) ?? [], depth + 1);
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
walk(idx.roots, 0);
|
|
927
|
+
return out;
|
|
928
|
+
}, [idx, closed]);
|
|
929
|
+
|
|
930
|
+
const treeRef = useRef<HTMLDivElement | null>(null);
|
|
931
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
932
|
+
const [viewH, setViewH] = useState(760);
|
|
933
|
+
useEffect(() => {
|
|
934
|
+
const el = treeRef.current;
|
|
935
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
936
|
+
const update = () => setViewH(el.clientHeight || 760);
|
|
937
|
+
update();
|
|
938
|
+
const ro = new ResizeObserver(update);
|
|
939
|
+
ro.observe(el);
|
|
940
|
+
return () => ro.disconnect();
|
|
941
|
+
}, []);
|
|
942
|
+
|
|
943
|
+
/* Scroll a row index into the window — the windowed replacement for
|
|
944
|
+
scrollIntoView, which can't target a row that isn't mounted. */
|
|
945
|
+
const ensureVisible = (i: number) => {
|
|
946
|
+
const el = treeRef.current;
|
|
947
|
+
if (!el || i < 0) return;
|
|
948
|
+
const top = i * ROW_H;
|
|
949
|
+
if (top < el.scrollTop) el.scrollTop = top;
|
|
950
|
+
else if (top + ROW_H > el.scrollTop + el.clientHeight)
|
|
951
|
+
el.scrollTop = top + ROW_H - el.clientHeight;
|
|
952
|
+
};
|
|
953
|
+
|
|
840
954
|
/* deep link: read #span=<id> on mount, write it on select */
|
|
841
955
|
useEffect(() => {
|
|
842
956
|
try {
|
|
843
957
|
const m = window.location.hash.match(/span=([\w.-]+)/);
|
|
844
958
|
if (m && idx.byId.has(m[1])) {
|
|
845
959
|
setSel(m[1]);
|
|
846
|
-
|
|
847
|
-
.getElementById(`rv-row-${m[1]}`)
|
|
848
|
-
?.scrollIntoView({ block: "nearest" });
|
|
960
|
+
ensureVisible(rows.findIndex((r) => r.span.id === m[1]));
|
|
849
961
|
}
|
|
850
962
|
} catch {
|
|
851
963
|
/* sandboxed embeds may not expose location — fine */
|
|
852
964
|
}
|
|
965
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
853
966
|
}, [idx]);
|
|
854
967
|
|
|
855
968
|
const select = (id: string) => {
|
|
@@ -877,42 +990,34 @@ export function RunViewer({ run }: { run: Run }) {
|
|
|
877
990
|
window.setTimeout(() => setCopied(false), 1400);
|
|
878
991
|
};
|
|
879
992
|
|
|
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
993
|
const selected = sel ? idx.byId.get(sel) : undefined;
|
|
890
994
|
const created = new Date(run.created_at);
|
|
891
995
|
|
|
892
|
-
const typeCounts
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
996
|
+
const { typeCounts, errorCount } = useMemo(() => {
|
|
997
|
+
const counts: Array<[string, number]> = [];
|
|
998
|
+
let errors = 0;
|
|
999
|
+
for (const s of run.spans) {
|
|
1000
|
+
if (s.status === "error") errors++;
|
|
1001
|
+
const entry = counts.find(([t]) => t === s.type);
|
|
1002
|
+
if (entry) entry[1]++;
|
|
1003
|
+
else counts.push([s.type, 1]);
|
|
1004
|
+
}
|
|
1005
|
+
return { typeCounts: counts, errorCount: errors };
|
|
1006
|
+
}, [run]);
|
|
900
1007
|
|
|
901
1008
|
/* keyboard: ↑/↓ move through visible rows, ←/→ collapse/expand. The
|
|
902
|
-
handler lives on the
|
|
1009
|
+
handler lives on the scroll container (which is focusable — a focused
|
|
1010
|
+
row can unmount when it scrolls out of the window). */
|
|
903
1011
|
const onTreeKey = (e: React.KeyboardEvent) => {
|
|
904
1012
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
905
1013
|
e.preventDefault();
|
|
906
1014
|
const at = rows.findIndex((r) => r.span.id === sel);
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
Math.min(rows.length - 1, Math.max(0, at + (e.key === "ArrowDown" ? 1 : -1)))
|
|
910
|
-
];
|
|
1015
|
+
const ni = Math.min(rows.length - 1, Math.max(0, at + (e.key === "ArrowDown" ? 1 : -1)));
|
|
1016
|
+
const next = rows[ni];
|
|
911
1017
|
if (next) {
|
|
912
1018
|
select(next.span.id);
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
el?.focus();
|
|
1019
|
+
ensureVisible(ni);
|
|
1020
|
+
treeRef.current?.focus();
|
|
916
1021
|
}
|
|
917
1022
|
} else if ((e.key === "ArrowRight" || e.key === "ArrowLeft") && sel) {
|
|
918
1023
|
e.preventDefault();
|
|
@@ -925,6 +1030,10 @@ export function RunViewer({ run }: { run: Run }) {
|
|
|
925
1030
|
}
|
|
926
1031
|
};
|
|
927
1032
|
|
|
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);
|
|
1036
|
+
|
|
928
1037
|
return (
|
|
929
1038
|
<div className="rv" style={{ fontFamily: T.sans, color: T.ink }}>
|
|
930
1039
|
<style>{`
|
|
@@ -1076,32 +1185,40 @@ export function RunViewer({ run }: { run: Run }) {
|
|
|
1076
1185
|
)}
|
|
1077
1186
|
</span>
|
|
1078
1187
|
</div>
|
|
1079
|
-
{/*
|
|
1080
|
-
|
|
1188
|
+
{/* Windowed: rows are fixed-height, so visibility is arithmetic.
|
|
1189
|
+
A spacer keeps the scrollbar honest; only [first, last) mount. */}
|
|
1081
1190
|
<div
|
|
1191
|
+
ref={treeRef}
|
|
1192
|
+
tabIndex={0}
|
|
1193
|
+
aria-label="Trace spans"
|
|
1082
1194
|
onKeyDown={onTreeKey}
|
|
1195
|
+
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
|
1083
1196
|
style={{ padding: "6px 0", overflowY: "auto", maxHeight: "min(72vh, 760px)" }}
|
|
1084
1197
|
>
|
|
1085
|
-
{rows.
|
|
1086
|
-
<
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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>
|
|
1105
1222
|
</div>
|
|
1106
1223
|
</div>
|
|
1107
1224
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@session-link/viewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|