@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.
- package/dist/AgentTree-D4d06rfH.js +293 -0
- package/dist/{ResearchReport-CSNpZ-pj.js → ResearchReport-D1TFmldm.js} +2 -1
- package/dist/{VirtualList-DLh2JE0F.js → VirtualList-DcSGbEtA.js} +122 -67
- package/dist/{index-gXcuwPOX.d.ts → index-Bv2iiZaN.d.ts} +135 -2
- package/dist/{index-YU_b9xCK.d.ts → index-CHA38az4.d.ts} +3 -61
- package/dist/index.d.ts +3 -3
- package/dist/index.js +9 -8
- package/dist/record/ResearchReport.js +1 -1
- package/dist/{record-Ct_MZeZC.js → record-C-EUpK-n.js} +3 -2
- package/dist/record.js +1 -1
- package/dist/report-viewer.js +96 -96
- package/dist/run/live.js +2 -2
- package/dist/run/record.d.ts +2 -2
- package/dist/run/record.js +5 -4
- package/dist/run.d.ts +3 -3
- package/dist/run.js +6 -5
- package/dist/runtime-viewer/{AgentGraphCanvas-DGq7_u8--WPFPgd72.js → AgentGraphCanvas-DGq7_u8--C6yDfIUT.js} +67 -67
- package/dist/runtime-viewer/{latest-events-CRq9sGkK-YFXUh9qO.js → latest-events-CRq9sGkK-CfoBl1dP.js} +215 -209
- package/dist/{trace-workspace-BiJbv9WQ.js → trace-workspace-B634-EcA.js} +287 -201
- package/dist/{tree-data-B_IXAIgg.js → tree-data-rg5voiY8.js} +1 -1
- package/dist/{AgentTree-CJRVpgPq.js → use-follow-tail-DZFn7QDO.js} +144 -385
- package/dist/vault-tree.js +1 -1
- package/dist/{workspace-CAdAoNmW.js → workspace-CJD7oOEa.js} +1 -1
- package/dist/{workspace-files-pane-C0PeY6rY.js → workspace-files-pane-D24G7H_Z.js} +1 -1
- package/dist/workspace-viewer.js +9265 -8896
- package/dist/workspace.js +2 -2
- package/package.json +5 -4
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
103
|
+
const goal = (key) => ({
|
|
104
|
+
key,
|
|
105
|
+
until: performance.now() + 2e3
|
|
106
|
+
});
|
|
110
107
|
const settle = () => {
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
113
|
-
if (performance.now() >
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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);
|