anentrypoint-design 0.0.330 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.330",
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",
@@ -130,6 +130,15 @@ export function injectCodeCopy(container) {
130
130
  const MD_STREAM_THROTTLE_MS = 120;
131
131
  const MD_STREAM_MIN_DELTA_CHARS = 40;
132
132
 
133
+ // requestIdleCallback with a setTimeout fallback (Safari/non-browser test
134
+ // contexts lack the real API). A settled historical message's parse is not
135
+ // latency-critical the way a streaming turn's is — deferring it off the
136
+ // critical path keeps a session-load burst of N historical bubbles from
137
+ // racing N synchronous parses on the same tick.
138
+ const scheduleIdle = typeof requestIdleCallback === 'function'
139
+ ? (fn) => requestIdleCallback(fn, { timeout: 500 })
140
+ : (fn) => setTimeout(fn, 0);
141
+
133
142
  function MdNode(p) {
134
143
  const refSink = (el) => {
135
144
  if (!el) return;
@@ -164,19 +173,34 @@ function MdNode(p) {
164
173
  // arrive (an empty bubble until the CDN import resolves reads as a
165
174
  // hang); the resolved render swaps in sanitized markdown in place.
166
175
  if (isMarkdownDegraded()) el.textContent = p.text || '';
167
- renderMarkdownCached(p.text || '').then((html) => {
168
- const swap = () => { el.innerHTML = html; injectCodeCopy(el); };
169
- // Don't blow away an active text selection inside this bubble mid-swap
170
- // (e.g. the user is mid-copy while a stream tick settles). Defer the
171
- // swap once, until the selection changes (cleared or moved elsewhere).
172
- const sel = typeof window !== 'undefined' ? window.getSelection() : null;
173
- if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
174
- const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); swap(); };
175
- document.addEventListener('selectionchange', onSelChange, { once: true });
176
- return;
177
- }
178
- swap();
179
- });
176
+ function doParse() {
177
+ renderMarkdownCached(p.text || '').then((html) => {
178
+ // The element may have been recycled (webjsx applyDiff reused this
179
+ // DOM node for a different message) or detached by the time an
180
+ // idle-deferred parse resolves -- re-check the source key still
181
+ // matches before swapping innerHTML into what could now be a
182
+ // completely different message's bubble.
183
+ if (el.dataset.mdSrc !== srcKey) return;
184
+ const swap = () => { el.innerHTML = html; injectCodeCopy(el); };
185
+ // Don't blow away an active text selection inside this bubble mid-swap
186
+ // (e.g. the user is mid-copy while a stream tick settles). Defer the
187
+ // swap once, until the selection changes (cleared or moved elsewhere).
188
+ const sel = typeof window !== 'undefined' ? window.getSelection() : null;
189
+ if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
190
+ const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); swap(); };
191
+ document.addEventListener('selectionchange', onSelChange, { once: true });
192
+ return;
193
+ }
194
+ swap();
195
+ });
196
+ }
197
+ // Streaming turns parse immediately (latency-critical: the user is
198
+ // watching this bubble grow). A settled historical message (no
199
+ // streamingCaret) is deferred to an idle slot -- not on the critical
200
+ // render path, so a page mounting many historical bubbles at once
201
+ // (session load) doesn't burst-parse them all synchronously.
202
+ if (p.streamingCaret) doParse();
203
+ else scheduleIdle(doParse);
180
204
  };
181
205
  return h('div', { class: 'chat-bubble chat-md', ref: refSink });
182
206
  }
@@ -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: stickyScroll },
148
- s.messages.length ? s.messages.map((m, i) => ChatMessage({ ...m, key: i }))
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
+ }