anentrypoint-design 0.0.331 → 0.0.332
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/247420.js +20 -20
- package/package.json +1 -1
- package/src/components/freddie.js +49 -3
- package/src/index.js +3 -1
- package/src/virtual-scroll.js +117 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.332",
|
|
4
4
|
"description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/247420.js",
|
|
@@ -14,6 +14,7 @@ import { register as registerDebug, unregister as unregisterDebug } from '../deb
|
|
|
14
14
|
import { queueMessage, watchReconnect, isOnline } from '../idb-outbox.js';
|
|
15
15
|
import { ChatMessage, ChatComposer } from './chat.js';
|
|
16
16
|
import { fmtTime, fmtAgo } from './sessions.js';
|
|
17
|
+
import { createVirtualizer, measureRef } from '../virtual-scroll.js';
|
|
17
18
|
|
|
18
19
|
const h = webjsx.createElement;
|
|
19
20
|
|
|
@@ -107,8 +108,21 @@ export const home = makePage((ctx) => {
|
|
|
107
108
|
|
|
108
109
|
// ---- chat ------------------------------------------------------------------
|
|
109
110
|
|
|
111
|
+
// Below this thread length, mount every message directly -- virtualization's
|
|
112
|
+
// scroll-listener + spacer-div overhead only pays for itself once the DOM
|
|
113
|
+
// node count it would otherwise mount is large enough to matter.
|
|
114
|
+
const VIRTUALIZE_THRESHOLD = 60;
|
|
115
|
+
|
|
110
116
|
export const chat = makePage((ctx) => {
|
|
111
117
|
Object.assign(ctx.state, { loading: false, messages: [], draft: '', sending: false });
|
|
118
|
+
const virtualizer = createVirtualizer();
|
|
119
|
+
let threadEl = null;
|
|
120
|
+
let range = { startIndex: 0, endIndex: 0, topSpacerPx: 0, bottomSpacerPx: 0 };
|
|
121
|
+
function recomputeRange() {
|
|
122
|
+
if (!threadEl) return;
|
|
123
|
+
virtualizer.setCount(ctx.state.messages.length);
|
|
124
|
+
range = virtualizer.computeRange(threadEl.scrollTop, threadEl.clientHeight);
|
|
125
|
+
}
|
|
112
126
|
// Offline outbox: a prompt sent while genuinely offline queues to
|
|
113
127
|
// IndexedDB and auto-flushes on the real 'online' event, rather than
|
|
114
128
|
// surfacing a hard error the user can't act on. True offline LLM
|
|
@@ -138,15 +152,47 @@ export const chat = makePage((ctx) => {
|
|
|
138
152
|
}
|
|
139
153
|
ctx.set({ sending: false });
|
|
140
154
|
}
|
|
155
|
+
// Combines the existing bottom-pin auto-scroll ref (stickyScroll) with the
|
|
156
|
+
// virtualizer's range recompute on every scroll tick. Both run off the
|
|
157
|
+
// SAME element -- one ref callback wiring both keeps a single listener
|
|
158
|
+
// registration instead of two refs fighting over the same node.
|
|
159
|
+
function threadRef(el) {
|
|
160
|
+
if (!el) { threadEl = null; return; }
|
|
161
|
+
threadEl = el;
|
|
162
|
+
stickyScroll(el);
|
|
163
|
+
if (!el.__vsScrollWired) {
|
|
164
|
+
el.__vsScrollWired = true;
|
|
165
|
+
el.addEventListener('scroll', () => { recomputeRange(); ctx.rerender(); }, { passive: true });
|
|
166
|
+
}
|
|
167
|
+
recomputeRange();
|
|
168
|
+
}
|
|
169
|
+
|
|
141
170
|
return () => {
|
|
142
171
|
const s = ctx.state;
|
|
172
|
+
const virtualized = s.messages.length > VIRTUALIZE_THRESHOLD;
|
|
173
|
+
let threadChildren;
|
|
174
|
+
if (!s.messages.length) {
|
|
175
|
+
threadChildren = [emptyState('send a prompt to start', Icon('forum'))];
|
|
176
|
+
} else if (!virtualized) {
|
|
177
|
+
threadChildren = s.messages.map((m, i) => ChatMessage({ ...m, key: i }));
|
|
178
|
+
} else {
|
|
179
|
+
recomputeRange();
|
|
180
|
+
const { startIndex, endIndex, topSpacerPx, bottomSpacerPx } = range;
|
|
181
|
+
threadChildren = [
|
|
182
|
+
h('div', { key: '_top_spacer', style: `height:${topSpacerPx}px` }),
|
|
183
|
+
...s.messages.slice(startIndex, endIndex).map((m, i) => {
|
|
184
|
+
const realIndex = startIndex + i;
|
|
185
|
+
return h('div', { key: 'vm' + realIndex, ref: measureRef(virtualizer, realIndex) }, ChatMessage({ ...m, key: realIndex }));
|
|
186
|
+
}),
|
|
187
|
+
h('div', { key: '_bottom_spacer', style: `height:${bottomSpacerPx}px` }),
|
|
188
|
+
];
|
|
189
|
+
}
|
|
143
190
|
return h('div', { class: 'fd-chat' },
|
|
144
191
|
PageHeader({ eyebrow: 'freddie', title: 'chat', lede: 'one-shot agent turns · POST /api/chat' }),
|
|
145
192
|
liveRegion(s.sending ? 'waiting for assistant reply' : ''),
|
|
146
193
|
h('div', { class: 'chat-thread fd-chat-thread', role: 'log', 'aria-label': 'chat messages',
|
|
147
|
-
ref:
|
|
148
|
-
|
|
149
|
-
: emptyState('send a prompt to start', Icon('forum')),
|
|
194
|
+
ref: threadRef },
|
|
195
|
+
...threadChildren,
|
|
150
196
|
s.sending ? ChatMessage({ role: 'assistant', typing: true, key: '_typing' }) : null),
|
|
151
197
|
ChatComposer({
|
|
152
198
|
value: s.draft,
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { registerChatElement, DsChat } from './web-components/ds-chat.js';
|
|
|
23
23
|
import { registerFreddieChatElement, FreddieChat } from './web-components/freddie-chat.js';
|
|
24
24
|
import { formatTime, formatDateTime, formatNumber, formatRelativeTime } from './locale.js';
|
|
25
25
|
import { queueMessage, listQueued, flushQueue, watchReconnect, isOnline } from './idb-outbox.js';
|
|
26
|
+
import { createVirtualizer, measureRef } from './virtual-scroll.js';
|
|
26
27
|
|
|
27
28
|
let _installed = false;
|
|
28
29
|
export async function installStyles(target) {
|
|
@@ -91,7 +92,8 @@ export {
|
|
|
91
92
|
theme, ThemeToggle,
|
|
92
93
|
t, registerLocale, getLocale, setLocale, availableLocales,
|
|
93
94
|
formatTime, formatDateTime, formatNumber, formatRelativeTime,
|
|
94
|
-
queueMessage, listQueued, flushQueue, watchReconnect, isOnline
|
|
95
|
+
queueMessage, listQueued, flushQueue, watchReconnect, isOnline,
|
|
96
|
+
createVirtualizer, measureRef
|
|
95
97
|
};
|
|
96
98
|
export { applyTheme, getTheme, resolvedTheme, onThemeChange, initTheme,
|
|
97
99
|
applyAccent, getAccent, applyDensity, getDensity } from './theme.js';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Windowed list virtualization for long message threads. Framework-agnostic:
|
|
2
|
+
// consumes a live scroll-container element + an items array, returns which
|
|
3
|
+
// index range to actually mount plus spacer heights for the rest, so a host
|
|
4
|
+
// component (chat.js's thread, freddie.js's chat page) only ever creates DOM
|
|
5
|
+
// nodes for items near the visible viewport instead of the whole history.
|
|
6
|
+
//
|
|
7
|
+
// Height-measurement strategy: real message bubbles vary in height (markdown,
|
|
8
|
+
// code blocks, images), so a fixed-row-height virtualizer would under/over-
|
|
9
|
+
// estimate scroll extent. This tracks a per-index height cache, seeded with
|
|
10
|
+
// an estimate and corrected once each item's real DOM node is measured after
|
|
11
|
+
// mount -- the same "measure on mount, estimate until then" approach used by
|
|
12
|
+
// react-window/react-virtualized, implemented directly over ResizeObserver
|
|
13
|
+
// with no new dependency.
|
|
14
|
+
|
|
15
|
+
import { register as registerDebug } from './debug.js';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_ESTIMATE_PX = 72;
|
|
18
|
+
const DEFAULT_OVERSCAN = 6;
|
|
19
|
+
let _instanceCounter = 0;
|
|
20
|
+
|
|
21
|
+
export function createVirtualizer({ estimateHeight = DEFAULT_ESTIMATE_PX, overscan = DEFAULT_OVERSCAN } = {}) {
|
|
22
|
+
const heights = new Map(); // index -> measured px height
|
|
23
|
+
let itemCount = 0;
|
|
24
|
+
|
|
25
|
+
function heightOf(i) {
|
|
26
|
+
return heights.has(i) ? heights.get(i) : estimateHeight;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function offsetOf(i) {
|
|
30
|
+
let sum = 0;
|
|
31
|
+
for (let k = 0; k < i; k++) sum += heightOf(k);
|
|
32
|
+
return sum;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function totalHeight() {
|
|
36
|
+
return offsetOf(itemCount);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// setCount MUST be called before computeRange on every render pass -- the
|
|
40
|
+
// item array can grow/shrink (new messages, session switch) and stale
|
|
41
|
+
// itemCount would compute a range against the wrong list length.
|
|
42
|
+
function setCount(n) {
|
|
43
|
+
itemCount = n;
|
|
44
|
+
// Drop cached heights for indices beyond the new count so a shrunk
|
|
45
|
+
// list (e.g. switching to a shorter session) doesn't leak stale
|
|
46
|
+
// measurements back in if the count grows again later.
|
|
47
|
+
for (const k of heights.keys()) if (k >= n) heights.delete(k);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Report a real measured height for index i (called from a ref callback
|
|
51
|
+
// once the actual DOM node exists). Returns true if the height changed
|
|
52
|
+
// enough to require a re-layout (avoids a re-render storm from sub-pixel
|
|
53
|
+
// ResizeObserver noise).
|
|
54
|
+
function reportHeight(i, px) {
|
|
55
|
+
const prev = heights.get(i);
|
|
56
|
+
if (prev != null && Math.abs(prev - px) < 1) return false;
|
|
57
|
+
heights.set(i, px);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// computeRange(scrollTop, viewportHeight) -> {startIndex, endIndex, topSpacerPx, bottomSpacerPx}
|
|
62
|
+
// endIndex is exclusive. startIndex/endIndex already include `overscan`
|
|
63
|
+
// items on each side so fast scrolling doesn't flash blank rows before
|
|
64
|
+
// the next range recomputes.
|
|
65
|
+
function computeRange(scrollTop, viewportHeight) {
|
|
66
|
+
if (itemCount === 0) return { startIndex: 0, endIndex: 0, topSpacerPx: 0, bottomSpacerPx: 0 };
|
|
67
|
+
let acc = 0;
|
|
68
|
+
let startIndex = 0;
|
|
69
|
+
for (; startIndex < itemCount; startIndex++) {
|
|
70
|
+
const h = heightOf(startIndex);
|
|
71
|
+
if (acc + h > scrollTop) break;
|
|
72
|
+
acc += h;
|
|
73
|
+
}
|
|
74
|
+
const topSpacerPx = acc;
|
|
75
|
+
let endIndex = startIndex;
|
|
76
|
+
let visibleAcc = 0;
|
|
77
|
+
for (; endIndex < itemCount; endIndex++) {
|
|
78
|
+
if (visibleAcc > viewportHeight) break;
|
|
79
|
+
visibleAcc += heightOf(endIndex);
|
|
80
|
+
}
|
|
81
|
+
startIndex = Math.max(0, startIndex - overscan);
|
|
82
|
+
endIndex = Math.min(itemCount, endIndex + overscan);
|
|
83
|
+
const topSpacer = offsetOf(startIndex);
|
|
84
|
+
const bottomSpacer = totalHeight() - offsetOf(endIndex);
|
|
85
|
+
return { startIndex, endIndex, topSpacerPx: topSpacer, bottomSpacerPx: Math.max(0, bottomSpacer) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Scroll-position-preserving resize: when content is prepended (e.g. the
|
|
89
|
+
// user scrolls up and older history is revealed) the browser's native
|
|
90
|
+
// scroll-anchoring is unreliable across a full DOM replace via applyDiff.
|
|
91
|
+
// Callers capture {scrollTop, scrollHeight} before the mutation and pass
|
|
92
|
+
// both here with the NEW scrollHeight after, to compute the delta to
|
|
93
|
+
// re-apply so the visually-anchored content doesn't jump.
|
|
94
|
+
function preserveScrollOnPrepend(prevScrollTop, prevScrollHeight, newScrollHeight) {
|
|
95
|
+
return prevScrollTop + (newScrollHeight - prevScrollHeight);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const instanceId = 'virtualizer-' + (_instanceCounter++);
|
|
99
|
+
registerDebug(instanceId, () => ({ itemCount, measuredCount: heights.size, totalHeightPx: totalHeight() }));
|
|
100
|
+
|
|
101
|
+
return { setCount, reportHeight, computeRange, totalHeight, heightOf, preserveScrollOnPrepend };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A ref-callback factory: wraps a per-item render so its real rendered height
|
|
105
|
+
// is measured via ResizeObserver and reported back into the virtualizer.
|
|
106
|
+
// Usage: h('div', {ref: measureRef(virtualizer, index)}, ...itemContent)
|
|
107
|
+
export function measureRef(virtualizer, index) {
|
|
108
|
+
return (el) => {
|
|
109
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
110
|
+
const ro = new ResizeObserver((entries) => {
|
|
111
|
+
const h = entries[0]?.contentRect?.height;
|
|
112
|
+
if (h != null) virtualizer.reportHeight(index, h);
|
|
113
|
+
});
|
|
114
|
+
ro.observe(el);
|
|
115
|
+
el.__vsResizeObserver = ro;
|
|
116
|
+
};
|
|
117
|
+
}
|