@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.
- package/RunViewer.tsx +199 -137
- 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
|
-
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
}, [
|
|
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
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1035
|
-
|
|
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
|
-
<
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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.
|
|
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",
|