@tangle-network/sandbox-ui 3.0.1 → 3.0.3

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 (28) hide show
  1. package/dist/AgentTree-D4d06rfH.js +293 -0
  2. package/dist/{ResearchReport-CSNpZ-pj.js → ResearchReport-D1TFmldm.js} +2 -1
  3. package/dist/{VirtualList-DLh2JE0F.js → VirtualList-DcSGbEtA.js} +122 -67
  4. package/dist/{index-gXcuwPOX.d.ts → index-Bv2iiZaN.d.ts} +135 -2
  5. package/dist/{index-YU_b9xCK.d.ts → index-CHA38az4.d.ts} +3 -61
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +9 -8
  8. package/dist/record/ResearchReport.js +1 -1
  9. package/dist/{record-Ct_MZeZC.js → record-C-EUpK-n.js} +3 -2
  10. package/dist/record.js +1 -1
  11. package/dist/report-viewer.js +96 -96
  12. package/dist/run/live.js +2 -2
  13. package/dist/run/record.d.ts +2 -2
  14. package/dist/run/record.js +5 -4
  15. package/dist/run.d.ts +3 -3
  16. package/dist/run.js +6 -5
  17. package/dist/runtime-viewer/{AgentGraphCanvas-DGq7_u8--WPFPgd72.js → AgentGraphCanvas-DGq7_u8--C6yDfIUT.js} +67 -67
  18. package/dist/runtime-viewer/{latest-events-CRq9sGkK-YFXUh9qO.js → latest-events-CRq9sGkK-CfoBl1dP.js} +215 -209
  19. package/dist/{trace-workspace-BiJbv9WQ.js → trace-workspace-B634-EcA.js} +287 -201
  20. package/dist/{tree-data-B_IXAIgg.js → tree-data-rg5voiY8.js} +1 -1
  21. package/dist/{AgentTree-CJRVpgPq.js → use-follow-tail-DZFn7QDO.js} +144 -385
  22. package/dist/vault-tree.js +1 -1
  23. package/dist/{workspace-CAdAoNmW.js → workspace-CJD7oOEa.js} +1 -1
  24. package/dist/{workspace-files-pane-C0PeY6rY.js → workspace-files-pane-D24G7H_Z.js} +1 -1
  25. package/dist/workspace-viewer.js +9265 -8896
  26. package/dist/workspace.js +2 -2
  27. package/package.json +5 -4
  28. package/dist/use-follow-tail-Ht7fZGQQ.js +0 -1
@@ -0,0 +1,293 @@
1
+ import { t as VirtualList } from "./VirtualList-DcSGbEtA.js";
2
+ import { C as ms, D as toolKey, E as textOf, T as roleOf, a as isLifecycle, c as anchorsOf, d as collapseIdleTurns, f as collapsePolls, g as eventMatches, h as callsOf, l as answeredBy, m as thinkingMarkers, n as ConversationRowView, o as rowItems, p as quietThinking, t as useFollowTail, u as collapseHollowTurns } from "./use-follow-tail-DZFn7QDO.js";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { AgentTimelineColumn, agentTimelineStepKind } from "@tangle-network/ui/chat/agent-timeline";
6
+ import { RunStatusIcon, runStatus, statusMeta } from "@tangle-network/ui/run/status-meta";
7
+ // src/run/record/Conversation.tsx
8
+ function Conversation({ index, actor, cutoff, query, category, selected, openSource, flags, onFlag, lifecycle = false, live = false, follow = live, opening }) {
9
+ const [opened, setOpened] = useState(() => {
10
+ const at = anchorsOf(index.events);
11
+ return new Set(index.events.filter((event) => event.detail.promptKind === "initial").map((event) => `prompt:${at.get(event.id) ?? event.id}`));
12
+ });
13
+ const scroll = useRef(null);
14
+ const anchors = useMemo(() => anchorsOf(index.events), [index]);
15
+ const anchor = useCallback((event) => anchors.get(event.id) ?? event.id, [anchors]);
16
+ const opener = (event) => openSource ? (sha256) => openSource(sha256, event) : void 0;
17
+ const setOpen = (key, open) => setOpened((current) => {
18
+ if (current.has(key) === open) return current;
19
+ const next = new Set(current);
20
+ if (open) next.add(key);
21
+ else next.delete(key);
22
+ return next;
23
+ });
24
+ const paired = (event, call) => {
25
+ const key = toolKey(event.node, call.id);
26
+ return index.calls.get(key)?.length === 1 ? index.results.get(key) ?? [] : [];
27
+ };
28
+ const target = useMemo(() => {
29
+ if (!selected) return void 0;
30
+ const event = index.byEvent.get(selected);
31
+ if (event?.detail.toolCallId !== void 0) {
32
+ const calls = index.calls.get(toolKey(event.node, event.detail.toolCallId));
33
+ if (calls?.length === 1) return calls[0].event.id;
34
+ }
35
+ return selected;
36
+ }, [selected, index]);
37
+ const items = useMemo(() => (index.byActor.get(actor) ?? []).filter((event) => {
38
+ if (ms(event.at) > cutoff) return false;
39
+ if (event.id === target) return true;
40
+ if (isLifecycle(event)) return lifecycle && eventMatches(event, index, query, category);
41
+ if (event.detail.toolCallId !== void 0) {
42
+ const matching = index.calls.get(toolKey(event.node, event.detail.toolCallId));
43
+ if (matching?.length === 1 && ms(matching[0].event.at) <= cutoff) return false;
44
+ }
45
+ const calls = callsOf(event);
46
+ if (!(event.detail.role || textOf(event) || event.detail.reasoning || event.detail.responseStatus === "error" || calls.length)) return false;
47
+ if (event.detail.role === "assistant" && !textOf(event) && !event.detail.reasoning && !calls.length && event.detail.responseStatus !== "error" && !event.detail.contentOmitted && (!thinkingMarkers(event.detail).length || quietThinking(event.detail))) return false;
48
+ if (eventMatches(event, index, query, category)) return true;
49
+ return calls.some((call) => {
50
+ const key = toolKey(event.node, call.id);
51
+ return index.calls.get(key)?.length === 1 && (index.results.get(key) ?? []).some((result) => ms(result.at) <= cutoff && eventMatches(result, index, query, category));
52
+ });
53
+ }), [
54
+ index,
55
+ actor,
56
+ cutoff,
57
+ query,
58
+ category,
59
+ lifecycle,
60
+ target
61
+ ]);
62
+ const shown = useMemo(() => {
63
+ if (!opening?.text || query || category !== "all" || items.some((event) => event.detail.role === "user" && event.detail.promptKind !== "steering")) return items;
64
+ const first = items[0]?.at ?? opening.at;
65
+ return [{
66
+ id: `${actor}:opening`,
67
+ node: actor,
68
+ at: first < opening.at ? first : opening.at,
69
+ kind: "message",
70
+ category: "other",
71
+ label: "prompt",
72
+ detail: {
73
+ role: "user",
74
+ publicText: opening.text,
75
+ promptKind: "initial"
76
+ }
77
+ }, ...items];
78
+ }, [
79
+ items,
80
+ opening,
81
+ actor,
82
+ query,
83
+ category
84
+ ]);
85
+ const rows = useMemo(() => collapseIdleTurns(collapseHollowTurns(collapsePolls(shown, {
86
+ callsOf,
87
+ answered: (event, id) => answeredBy(index.results.get(toolKey(event.node, id))),
88
+ keyOf: anchor
89
+ }), {
90
+ callsOf,
91
+ captured: (event, id) => (index.results.get(toolKey(event.node, id)) ?? []).some((result) => textOf(result)),
92
+ keyOf: anchor
93
+ }), callsOf), [
94
+ shown,
95
+ index,
96
+ anchor
97
+ ]);
98
+ const thinkingSpan = useMemo(() => {
99
+ const spans = /* @__PURE__ */ new Map();
100
+ let previous = null;
101
+ for (const event of index.byActor.get(actor) ?? []) {
102
+ const at = ms(event.at);
103
+ if (previous !== null && Number.isFinite(at) && thinkingMarkers(event.detail).length) spans.set(event.id, Math.max(0, at - previous));
104
+ if (Number.isFinite(at)) previous = at;
105
+ }
106
+ return spans;
107
+ }, [index, actor]);
108
+ const rowOf = useMemo(() => new Map(rows.flatMap((row) => (row.kind === "event" ? [row.event] : row.events).map((event) => [event.id, row.key]))), [rows]);
109
+ const retained = (index.byActor.get(actor) ?? []).some((item) => !isLifecycle(item) && (item.detail.role || textOf(item) || callsOf(item).length));
110
+ const tail = rows.at(-1), event = tail?.kind === "event" ? tail.event : tail?.events.at(-1);
111
+ const { following } = useFollowTail(scroll, {
112
+ identity: actor,
113
+ content: rows,
114
+ follow,
115
+ target
116
+ });
117
+ useEffect(() => {
118
+ if (!selected) return;
119
+ const event = index.byEvent.get(selected);
120
+ if (!event) return;
121
+ const keys = [`prompt:${anchor(event)}`, `reasoning:${anchor(event)}`];
122
+ if (event.detail.toolCallId !== void 0) for (const match of index.calls.get(toolKey(event.node, event.detail.toolCallId)) ?? []) keys.push(`tool:${anchor(match.event)}:${match.call.id}`, rowOf.get(match.event.id) ?? "");
123
+ for (const call of callsOf(event)) keys.push(`tool:${anchor(event)}:${call.id}`);
124
+ const row = rowOf.get(selected);
125
+ if (row?.startsWith("polls:")) keys.push(row);
126
+ setOpened((current) => /* @__PURE__ */ new Set([...current, ...keys.filter(Boolean)]));
127
+ }, [selected, index]);
128
+ const flagMarks = (event) => {
129
+ const marks = flags?.get(event.id);
130
+ if (!marks?.length) return null;
131
+ return /* @__PURE__ */ jsx("span", {
132
+ className: "event-flags",
133
+ children: marks.map((flag) => /* @__PURE__ */ jsx("button", {
134
+ type: "button",
135
+ className: `event-flag polarity-${flag.polarity}`,
136
+ title: flag.title,
137
+ "data-flag": flag.dimension,
138
+ onClick: () => onFlag?.(flag, event),
139
+ children: flag.dimension
140
+ }, `${flag.dimension}:${flag.label}`))
141
+ });
142
+ };
143
+ const keyOf = useCallback((row) => row.key, []);
144
+ const estimate = useCallback((row) => {
145
+ if (row.kind !== "event") return 36;
146
+ if (isLifecycle(row.event)) return 26;
147
+ return !textOf(row.event) && callsOf(row.event).length ? 16 + 36 * callsOf(row.event).length : 96;
148
+ }, []);
149
+ const context = {
150
+ index,
151
+ cutoff,
152
+ selected,
153
+ opened,
154
+ anchor,
155
+ opener,
156
+ setOpen,
157
+ flagMarks,
158
+ paired,
159
+ thinkingSpan,
160
+ flags,
161
+ live
162
+ };
163
+ const itemsOf = useMemo(() => rows.map((row) => rowItems(row, context)), [
164
+ rows,
165
+ opened,
166
+ cutoff,
167
+ selected,
168
+ live,
169
+ index,
170
+ thinkingSpan
171
+ ]);
172
+ const turns = useMemo(() => {
173
+ let previous;
174
+ return rows.map((row) => {
175
+ const event = row.kind === "event" ? row.event : row.events[0];
176
+ const turn = event.detail.role === "assistant" && !isLifecycle(event) && (!previous || previous.detail.role === "user" || (event.detail.attempt !== void 0 ? event.detail.attempt !== previous.detail.attempt : ms(event.at) - ms(previous.at) > 12e4));
177
+ previous = row.kind === "event" ? event : row.events.at(-1);
178
+ return turn;
179
+ });
180
+ }, [rows]);
181
+ return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs("div", {
182
+ className: "record-conversation conversation-scroll",
183
+ "data-chat": true,
184
+ ref: scroll,
185
+ tabIndex: 0,
186
+ "aria-label": "Scrollable retained conversation",
187
+ children: [!rows.length && /* @__PURE__ */ jsx("p", {
188
+ className: "chat-empty",
189
+ children: query || category !== "all" ? "No messages match this filter." : retained ? "No messages at the selected time." : "Conversation not retained. Its absence does not mean the agent did no work."
190
+ }), /* @__PURE__ */ jsx(AgentTimelineColumn, {
191
+ className: "ar-chat",
192
+ children: /* @__PURE__ */ jsx(VirtualList, {
193
+ items: rows,
194
+ keyOf,
195
+ render: (row, i) => /* @__PURE__ */ jsx(ConversationRowView, {
196
+ row,
197
+ items: itemsOf[i] ?? [],
198
+ previous: i && itemsOf[i - 1]?.length ? agentTimelineStepKind(itemsOf[i - 1].at(-1)) : void 0,
199
+ turn: turns[i],
200
+ context
201
+ }),
202
+ scroller: scroll,
203
+ target: target ? rowOf.get(target) : void 0,
204
+ estimate,
205
+ arrival: live ? {
206
+ identity: actor,
207
+ id: event?.id,
208
+ key: tail?.key,
209
+ following
210
+ } : void 0
211
+ })
212
+ })]
213
+ }) });
214
+ }
215
+ // src/run/record/AgentTree.tsx
216
+ /** Retained identity and recorded time define the hierarchy; labels never join sessions or invent parents. */
217
+ function AgentTree({ index, actor, cutoff, select }) {
218
+ const roots = useMemo(() => {
219
+ const nodes = index.actors.filter((node) => node.kind !== "finding");
220
+ const byId = new Map(nodes.map((node) => [node.id, node]));
221
+ const children = /* @__PURE__ */ new Map();
222
+ for (const node of nodes) {
223
+ if (!node.parent) continue;
224
+ const parent = index.canonical(node.parent), siblings = children.get(parent) ?? [];
225
+ siblings.push(node);
226
+ children.set(parent, siblings);
227
+ }
228
+ const visited = /* @__PURE__ */ new Set();
229
+ const visit = (node) => {
230
+ if (visited.has(node.id)) return [];
231
+ visited.add(node.id);
232
+ return [{
233
+ node,
234
+ children: (children.get(node.id) ?? []).flatMap(visit)
235
+ }];
236
+ };
237
+ const roots = nodes.filter((node) => !node.parent || !byId.has(index.canonical(node.parent))).flatMap(visit);
238
+ for (const node of nodes) roots.push(...visit(node));
239
+ return roots;
240
+ }, [index]);
241
+ const renderNodes = (entries, nested = false) => /* @__PURE__ */ jsx("ul", {
242
+ className: nested ? "m-0 ml-3 list-none border-l border-border pl-2" : "m-0 grid list-none gap-1 p-0",
243
+ children: entries.map(({ node, children }) => {
244
+ const firstEvent = index.byActor.get(node.id)?.[0];
245
+ const begin = node.start ? ms(node.start) : firstEvent ? ms(firstEvent.at) : void 0;
246
+ const future = begin !== void 0 && begin > cutoff, pending = typeof node.end === "string" && ms(node.end) > cutoff;
247
+ const status = runStatus(future || pending ? "unknown" : node.status ?? "unknown");
248
+ const state = future ? "Not yet started" : pending ? "Not yet settled" : statusMeta(status).label;
249
+ return /* @__PURE__ */ jsxs("li", {
250
+ className: "min-w-0",
251
+ children: [/* @__PURE__ */ jsxs("button", {
252
+ type: "button",
253
+ className: "flex w-full min-w-0 items-start gap-2 rounded border border-transparent px-2 py-1.5 text-left text-sm text-foreground hover:border-border focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring aria-pressed:border-border aria-pressed:bg-muted data-[future]:opacity-55",
254
+ "aria-label": "Read " + roleOf(node) + " " + node.label + " conversation. " + state + ".",
255
+ "aria-pressed": actor === node.id,
256
+ "data-node": node.id,
257
+ "data-future": future ? "" : void 0,
258
+ title: node.label + (node.assignment ? " · " + node.assignment : "") + " · " + state,
259
+ onClick: () => select(node.id),
260
+ children: [/* @__PURE__ */ jsx(RunStatusIcon, {
261
+ status,
262
+ className: "mt-0.5 size-3.5 shrink-0",
263
+ "aria-hidden": true
264
+ }), /* @__PURE__ */ jsxs("span", {
265
+ className: "grid min-w-0 gap-0.5 break-words",
266
+ children: [/* @__PURE__ */ jsx("span", {
267
+ className: "font-semibold",
268
+ children: node.label
269
+ }), /* @__PURE__ */ jsxs("span", {
270
+ className: "text-muted-foreground",
271
+ children: [
272
+ roleOf(node),
273
+ " · ",
274
+ state
275
+ ]
276
+ })]
277
+ })]
278
+ }), children.length > 0 && renderNodes(children, true)]
279
+ }, node.id);
280
+ })
281
+ });
282
+ return /* @__PURE__ */ jsx("div", {
283
+ className: "record-agent-tree max-h-80 overflow-auto px-2 py-2",
284
+ "data-graph": true,
285
+ role: "group",
286
+ "aria-label": "Recorded agent hierarchy. Select an agent to read its conversation.",
287
+ children: roots.length ? renderNodes(roots) : /* @__PURE__ */ jsx("p", {
288
+ className: "text-sm text-muted-foreground",
289
+ children: "No agent topology was retained."
290
+ })
291
+ });
292
+ }
293
+ export { Conversation as n, AgentTree as t };
@@ -1,4 +1,5 @@
1
- import { a as fmt, c as modelIdentity, d as recordFlowTrace, f as roleOf, h as utcTime, i as eventSource, m as toolKey, n as Conversation, o as indexRecord, p as textOf, r as eventMatches, s as interval, t as AgentTree, u as ms } from "./AgentTree-CJRVpgPq.js";
1
+ import { C as ms, D as toolKey, E as textOf, O as utcTime, T as roleOf, _ as eventSource, b as interval, g as eventMatches, v as fmt, w as recordFlowTrace, x as modelIdentity, y as indexRecord } from "./use-follow-tail-DZFn7QDO.js";
2
+ import { n as Conversation, t as AgentTree } from "./AgentTree-D4d06rfH.js";
2
3
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@tangle-network/ui/primitives";
3
4
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
5
  import { createContext, memo, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
@@ -1,23 +1,13 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { Fragment as Fragment$1, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  // src/run/record/VirtualList.tsx
4
- /**
5
- * Renders only the rows near the viewport of `scroller`. Row heights are measured as they render; unmeasured
6
- * rows use `estimate`. Spacers keep the scroll height, so the browser's scroll anchoring holds the reading place.
7
- */
8
- function VirtualList({ items, keyOf, render, scroller, estimate = 96, overscan = 900, target, origin, targetAlign = "start", virtualize = true, keepMounted }) {
4
+ /** Row heights measured as rows render through `bind`; `version` changes when a measured height changes. */
5
+ function useMeasuredHeights() {
9
6
  const heights = useRef(/* @__PURE__ */ new Map());
10
- const [version, setVersion] = useState(0);
11
- const [viewport, setViewport] = useState({
12
- top: 0,
13
- height: 900
14
- });
7
+ const rows = useRef(/* @__PURE__ */ new Map());
15
8
  const observer = useRef(null);
16
9
  const frame = useRef(0);
17
- const originTop = () => {
18
- const element = scroller.current, node = origin?.current;
19
- return element && node ? node.getBoundingClientRect().top - (element === document.scrollingElement ? 0 : element.getBoundingClientRect().top + element.clientTop) + element.scrollTop : 0;
20
- };
10
+ const [version, setVersion] = useState(0);
21
11
  const observe = (element) => {
22
12
  if (!observer.current && typeof ResizeObserver !== "undefined") observer.current = new ResizeObserver((entries) => {
23
13
  let changed = false;
@@ -45,6 +35,31 @@ function VirtualList({ items, keyOf, render, scroller, estimate = 96, overscan =
45
35
  observer.current?.disconnect();
46
36
  };
47
37
  }, []);
38
+ const bind = (key) => (element) => {
39
+ const previous = rows.current.get(key);
40
+ if (previous) observer.current?.unobserve(previous);
41
+ if (element) {
42
+ rows.current.set(key, element);
43
+ observe(element);
44
+ } else rows.current.delete(key);
45
+ };
46
+ return {
47
+ heights,
48
+ rows,
49
+ version,
50
+ bind
51
+ };
52
+ }
53
+ /** The scroller's visible window, measured from `origin` when the rows start below other content. */
54
+ function useViewport(scroller, origin) {
55
+ const [viewport, setViewport] = useState({
56
+ top: 0,
57
+ height: 900
58
+ });
59
+ const originTop = () => {
60
+ const element = scroller.current, node = origin?.current;
61
+ return element && node ? node.getBoundingClientRect().top - (element === document.scrollingElement ? 0 : element.getBoundingClientRect().top + element.clientTop) + element.scrollTop : 0;
62
+ };
48
63
  useEffect(() => {
49
64
  const element = scroller.current;
50
65
  if (!element) return;
@@ -73,59 +88,42 @@ function VirtualList({ items, keyOf, render, scroller, estimate = 96, overscan =
73
88
  resize.disconnect();
74
89
  };
75
90
  }, [scroller, origin]);
76
- const offsets = useMemo(() => {
77
- const result = new Float64Array(items.length + 1);
78
- const guess = typeof estimate === "function" ? estimate : () => estimate;
79
- for (let i = 0; i < items.length; i++) result[i + 1] = result[i] + (heights.current.get(keyOf(items[i])) ?? guess(items[i]));
80
- return result;
81
- }, [
82
- items,
83
- keyOf,
84
- estimate,
85
- version
86
- ]);
87
- const find = (y) => {
88
- let low = 0, high = items.length;
89
- while (low < high) {
90
- const mid = low + high >> 1;
91
- if (offsets[mid + 1] <= y) low = mid + 1;
92
- else high = mid;
93
- }
94
- return low;
95
- };
96
- const start = virtualize ? Math.max(0, find(viewport.top - overscan)) : 0;
97
- const end = virtualize ? Math.min(items.length, find(viewport.top + viewport.height + overscan) + 1) : items.length;
98
- const scrollTo = (key, align) => {
99
- const element = scroller.current;
100
- const index = items.findIndex((item) => keyOf(item) === key);
101
- if (!element || index < 0) return false;
102
- const origin = originTop(), top = origin + offsets[index];
103
- const bottom = origin + offsets[index + 1];
104
- if (align || top < element.scrollTop) element.scrollTop = Math.max(0, top - 8);
105
- else if (bottom > element.scrollTop + element.clientHeight) element.scrollTop = Math.max(0, bottom - element.clientHeight);
106
- return true;
91
+ return {
92
+ viewport,
93
+ originTop
107
94
  };
95
+ }
96
+ /**
97
+ * A target is settled, not jumped to once: rows above it are measured only after the first jump renders them, which
98
+ * moves it. Each measurement, viewport move and change of the rows (a record that arrived late) re-aligns it until the
99
+ * reader scrolls, or after two seconds. Home and End settle the first and last row the same way.
100
+ */
101
+ function useSettleTarget(scroller, { target, align, items, keyOf, offsets, originTop, rows, version, viewport }) {
108
102
  const pending = useRef(null);
109
- const rows = useRef(/* @__PURE__ */ new Map());
103
+ const goal = (key) => ({
104
+ key,
105
+ until: performance.now() + 2e3
106
+ });
110
107
  const settle = () => {
111
- const goal = pending.current;
112
- if (!goal) return;
113
- if (performance.now() > goal.until) {
108
+ const current = pending.current;
109
+ if (!current) return;
110
+ if (performance.now() > current.until) {
114
111
  pending.current = null;
115
112
  return;
116
113
  }
117
- if (!scrollTo(goal.key, targetAlign === "start")) return;
118
- const row = rows.current.get(goal.key);
119
- if (targetAlign === "start" && row?.isConnected) {
114
+ const element = scroller.current, index = items.findIndex((item) => keyOf(item) === current.key);
115
+ if (!element || index < 0) return;
116
+ const origin = originTop(), top = origin + offsets[index], bottom = origin + offsets[index + 1];
117
+ if (align === "start" || top < element.scrollTop) element.scrollTop = Math.max(0, top - 8);
118
+ else if (bottom > element.scrollTop + element.clientHeight) element.scrollTop = Math.max(0, bottom - element.clientHeight);
119
+ const row = rows.current.get(current.key);
120
+ if (align === "start" && row?.isConnected) {
120
121
  const box = row.getBoundingClientRect();
121
122
  if (box.top < 72 || box.top > window.innerHeight - 96) window.scrollBy({ top: box.top - 120 });
122
123
  }
123
124
  };
124
125
  useLayoutEffect(() => {
125
- pending.current = target ? {
126
- key: target,
127
- until: performance.now() + 2e3
128
- } : null;
126
+ pending.current = target ? goal(target) : null;
129
127
  settle();
130
128
  }, [target]);
131
129
  useLayoutEffect(settle, [
@@ -138,10 +136,7 @@ function VirtualList({ items, keyOf, render, scroller, estimate = 96, overscan =
138
136
  if (!element) return;
139
137
  const release = (event) => {
140
138
  const key = event instanceof KeyboardEvent && event.target === element && !event.defaultPrevented ? event.key : "";
141
- pending.current = items.length && (key === "Home" || key === "End") ? {
142
- key: keyOf(items[key === "Home" ? 0 : items.length - 1]),
143
- until: performance.now() + 2e3
144
- } : null;
139
+ pending.current = items.length && (key === "Home" || key === "End") ? goal(keyOf(items[key === "Home" ? 0 : items.length - 1])) : null;
145
140
  if (pending.current) {
146
141
  event.preventDefault();
147
142
  settle();
@@ -163,14 +158,74 @@ function VirtualList({ items, keyOf, render, scroller, estimate = 96, overscan =
163
158
  keyOf,
164
159
  offsets
165
160
  ]);
166
- const bind = (key) => (element) => {
167
- const previous = rows.current.get(key);
168
- if (previous) observer.current?.unobserve(previous);
169
- if (element) {
170
- rows.current.set(key, element);
171
- observe(element);
172
- } else rows.current.delete(key);
161
+ }
162
+ /** Animate a new followed row once; measurements and reader-state updates keep the same arrival identity. */
163
+ function useArrival(scroller, rows, arrival) {
164
+ const identity = arrival?.identity;
165
+ const prior = useRef({
166
+ identity,
167
+ id: arrival?.id
168
+ });
169
+ useLayoutEffect(() => {
170
+ const animate = prior.current.identity === identity && !!prior.current.id && prior.current.id !== arrival?.id;
171
+ prior.current = {
172
+ identity,
173
+ id: arrival?.id
174
+ };
175
+ if (!animate || !arrival?.key || !arrival.following.current) return;
176
+ const frame = requestAnimationFrame(() => {
177
+ const element = scroller.current, row = rows.current.get(arrival.key)?.firstElementChild;
178
+ if (!element || !row || !arrival.following.current) return;
179
+ const style = getComputedStyle(element), value = style.getPropertyValue("--duration-stream").trim(), duration = parseFloat(value) * (value.endsWith("ms") ? 1 : 1e3);
180
+ if (duration > 0 && !matchMedia("(prefers-reduced-motion: reduce)").matches) row.animate([{ opacity: 0 }, { opacity: 1 }], {
181
+ duration,
182
+ easing: style.getPropertyValue("--ease-expo").trim() || "linear"
183
+ });
184
+ });
185
+ return () => cancelAnimationFrame(frame);
186
+ }, [identity, arrival?.id]);
187
+ }
188
+ /**
189
+ * Renders only the rows near the viewport of `scroller`. Row heights are measured as they render; unmeasured
190
+ * rows use `estimate`. Spacers keep the scroll height, so the browser's scroll anchoring holds the reading place.
191
+ */
192
+ function VirtualList({ items, keyOf, render, scroller, estimate = 96, overscan = 900, target, origin, targetAlign = "start", virtualize = true, keepMounted, arrival }) {
193
+ const { heights, rows, version, bind } = useMeasuredHeights();
194
+ const { viewport, originTop } = useViewport(scroller, origin);
195
+ useArrival(scroller, rows, arrival);
196
+ const offsets = useMemo(() => {
197
+ const result = new Float64Array(items.length + 1);
198
+ const guess = typeof estimate === "function" ? estimate : () => estimate;
199
+ for (let i = 0; i < items.length; i++) result[i + 1] = result[i] + (heights.current.get(keyOf(items[i])) ?? guess(items[i]));
200
+ return result;
201
+ }, [
202
+ items,
203
+ keyOf,
204
+ estimate,
205
+ version
206
+ ]);
207
+ useSettleTarget(scroller, {
208
+ target,
209
+ align: targetAlign,
210
+ items,
211
+ keyOf,
212
+ offsets,
213
+ originTop,
214
+ rows,
215
+ version,
216
+ viewport
217
+ });
218
+ const find = (y) => {
219
+ let low = 0, high = items.length;
220
+ while (low < high) {
221
+ const mid = low + high >> 1;
222
+ if (offsets[mid + 1] <= y) low = mid + 1;
223
+ else high = mid;
224
+ }
225
+ return low;
173
226
  };
227
+ const start = virtualize ? Math.max(0, find(viewport.top - overscan)) : 0;
228
+ const end = virtualize ? Math.min(items.length, find(viewport.top + viewport.height + overscan) + 1) : items.length;
174
229
  const indices = Array.from({ length: end - start }, (_, i) => start + i);
175
230
  const kept = keepMounted ? items.findIndex((item) => keyOf(item) === keepMounted) : -1;
176
231
  if (kept >= 0 && kept < start) indices.unshift(kept);