@session-link/viewer 0.2.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 +199 -137
  2. package/package.json +1 -1
package/RunViewer.tsx CHANGED
@@ -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)}` : ""}`
@@ -740,6 +742,9 @@ function TreeRow({
740
742
  {hasKids ? (
741
743
  <button
742
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}
743
748
  onClick={(e) => {
744
749
  e.stopPropagation();
745
750
  onToggle();
@@ -846,6 +851,9 @@ export function RunViewer({ run, src }: { run?: Run; src?: string }) {
846
851
 
847
852
  useEffect(() => {
848
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);
849
857
  let alive = true;
850
858
  fetch(src)
851
859
  .then(async (r) => {
@@ -904,14 +912,28 @@ export function RunViewer({ run, src }: { run?: Run; src?: string }) {
904
912
  * windowing math depends on, enforced by an explicit height on the row. */
905
913
  const ROW_H = 30;
906
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;
907
918
 
908
- function LoadedViewer({ run }: { run: Run }) {
909
- const idx = useMemo(() => indexRun(run), [run]);
910
- const [sel, setSel] = useState<string | null>(idx.roots[0]?.id ?? null);
911
- const [closed, setClosed] = useState<Set<string>>(new Set());
912
- const [raw, setRaw] = useState(false);
913
- const [copied, setCopied] = useState(false);
914
-
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
+ }) {
915
937
  /* The visible tree, flattened. Thousands of rows are normal for real
916
938
  agent traces, so only the window around the scroll position mounts —
917
939
  this array is cheap (plain objects), the DOM is what's rationed. */
@@ -945,65 +967,35 @@ function LoadedViewer({ run }: { run: Run }) {
945
967
  const ensureVisible = (i: number) => {
946
968
  const el = treeRef.current;
947
969
  if (!el || i < 0) return;
948
- const top = i * ROW_H;
970
+ const top = PAD_TOP + i * ROW_H;
949
971
  if (top < el.scrollTop) el.scrollTop = top;
950
972
  else if (top + ROW_H > el.scrollTop + el.clientHeight)
951
973
  el.scrollTop = top + ROW_H - el.clientHeight;
952
974
  };
953
975
 
954
- /* deep link: read #span=<id> on mount, write it on select */
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);
955
981
  useEffect(() => {
956
- try {
957
- const m = window.location.hash.match(/span=([\w.-]+)/);
958
- if (m && idx.byId.has(m[1])) {
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
- }
982
+ if (sel === lastSel.current) return;
983
+ lastSel.current = sel;
984
+ ensureVisible(rows.findIndex((r) => r.span.id === sel));
965
985
  // eslint-disable-next-line react-hooks/exhaustive-deps
966
- }, [idx]);
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);
986
+ }, [sel, rows]);
995
987
 
996
988
  const { typeCounts, errorCount } = useMemo(() => {
997
989
  const counts: Array<[string, number]> = [];
998
990
  let errors = 0;
999
- for (const s of run.spans) {
991
+ for (const s of idx.byId.values()) {
1000
992
  if (s.status === "error") errors++;
1001
993
  const entry = counts.find(([t]) => t === s.type);
1002
994
  if (entry) entry[1]++;
1003
995
  else counts.push([s.type, 1]);
1004
996
  }
1005
997
  return { typeCounts: counts, errorCount: errors };
1006
- }, [run]);
998
+ }, [idx]);
1007
999
 
1008
1000
  /* keyboard: ↑/↓ move through visible rows, ←/→ collapse/expand. The
1009
1001
  handler lives on the scroll container (which is focusable — a focused
@@ -1012,16 +1004,18 @@ function LoadedViewer({ run }: { run: Run }) {
1012
1004
  if (e.key === "ArrowDown" || e.key === "ArrowUp") {
1013
1005
  e.preventDefault();
1014
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;
1015
1009
  const ni = Math.min(rows.length - 1, Math.max(0, at + (e.key === "ArrowDown" ? 1 : -1)));
1016
1010
  const next = rows[ni];
1017
1011
  if (next) {
1018
- select(next.span.id);
1012
+ onSelect(next.span.id);
1019
1013
  ensureVisible(ni);
1020
- treeRef.current?.focus();
1014
+ treeRef.current?.focus({ preventScroll: true });
1021
1015
  }
1022
1016
  } else if ((e.key === "ArrowRight" || e.key === "ArrowLeft") && sel) {
1023
1017
  e.preventDefault();
1024
- setClosed((prev) => {
1018
+ onSetClosed((prev) => {
1025
1019
  const next = new Set(prev);
1026
1020
  if (e.key === "ArrowRight") next.delete(sel);
1027
1021
  else next.add(sel);
@@ -1030,9 +1024,151 @@ function LoadedViewer({ run }: { run: Run }) {
1030
1024
  }
1031
1025
  };
1032
1026
 
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);
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 }) {
1128
+ const idx = useMemo(() => indexRun(run), [run]);
1129
+ const [sel, setSel] = useState<string | null>(idx.roots[0]?.id ?? null);
1130
+ const [closed, setClosed] = useState<Set<string>>(new Set());
1131
+ const [raw, setRaw] = useState(false);
1132
+ const [copied, setCopied] = useState(false);
1133
+
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. */
1136
+ useEffect(() => {
1137
+ try {
1138
+ const m = window.location.hash.match(/span=([\w.-]+)/);
1139
+ if (m && idx.byId.has(m[1])) setSel(m[1]);
1140
+ } catch {
1141
+ /* sandboxed embeds may not expose location — fine */
1142
+ }
1143
+ }, [idx]);
1144
+
1145
+ const select = (id: string) => {
1146
+ setSel(id);
1147
+ setCopied(false);
1148
+ try {
1149
+ history.replaceState(null, "", `#span=${id}`);
1150
+ } catch {
1151
+ /* ignore in sandboxes */
1152
+ }
1153
+ };
1154
+
1155
+ const copyLink = () => {
1156
+ if (!sel) return;
1157
+ let text = `#span=${sel}`;
1158
+ try {
1159
+ const u = new URL(window.location.href);
1160
+ u.hash = `span=${sel}`;
1161
+ text = u.toString();
1162
+ } catch {
1163
+ /* keep relative anchor */
1164
+ }
1165
+ navigator.clipboard?.writeText(text).catch(() => {});
1166
+ setCopied(true);
1167
+ window.setTimeout(() => setCopied(false), 1400);
1168
+ };
1169
+
1170
+ const selected = sel ? idx.byId.get(sel) : undefined;
1171
+ const created = new Date(run.created_at);
1036
1172
 
1037
1173
  return (
1038
1174
  <div className="rv" style={{ fontFamily: T.sans, color: T.ink }}>
@@ -1140,87 +1276,13 @@ function LoadedViewer({ run }: { run: Run }) {
1140
1276
 
1141
1277
  {/* ---------------------------------------------------- two panels */}
1142
1278
  <div className="rv-cols">
1143
- <div className="rv-tree">
1144
- <div
1145
- style={{
1146
- display: "flex",
1147
- justifyContent: "space-between",
1148
- alignItems: "baseline",
1149
- padding: "12px 12px 8px",
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>
1279
+ <TreeColumn
1280
+ idx={idx}
1281
+ sel={sel}
1282
+ closed={closed}
1283
+ onSelect={select}
1284
+ onSetClosed={setClosed}
1285
+ />
1224
1286
 
1225
1287
  <div style={{ flex: 1, minWidth: 0, padding: "16px 20px 24px" }}>
1226
1288
  {selected ? (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@session-link/viewer",
3
- "version": "0.2.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",