@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.
Files changed (2) hide show
  1. package/RunViewer.tsx +170 -53
  2. 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
- padding: "7px 12px 7px 0",
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
- export function RunViewer({ run }: { run: Run }) {
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&apos;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
- document
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: 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
- }
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 rows container so it fires while a row has focus. */
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 next =
908
- rows[
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
- const el = document.getElementById(`rv-row-${next.span.id}`);
914
- el?.scrollIntoView({ block: "nearest" });
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
- {/* NOTE: virtualize this list (e.g. react-virtuoso) before real
1080
- agent traces arrive thousands of rows are normal. */}
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.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
- ))}
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.1.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",