@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.
Files changed (2) hide show
  1. package/RunViewer.tsx +308 -129
  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,
@@ -711,7 +711,9 @@ function TreeRow({
711
711
  <div
712
712
  id={`rv-row-${span.id}`}
713
713
  role="button"
714
- tabIndex={0}
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
- padding: "7px 12px 7px 0",
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
- export function RunViewer({ run }: { run: Run }) {
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&apos;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
- <div className="rv-tree">
1035
- <div
1036
- style={{
1037
- display: "flex",
1038
- justifyContent: "space-between",
1039
- alignItems: "baseline",
1040
- padding: "12px 12px 8px",
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.0",
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",