Changeset 0.28.6 (#192)
This commit is contained in:
277
src/js/virtual-scroll.js
Normal file
277
src/js/virtual-scroll.js
Normal file
@@ -0,0 +1,277 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Virtual Scroll
|
||||
// ==========================================
|
||||
// Viewport-windowed message rendering for long conversations.
|
||||
// Renders a window of messages around the viewport. As the user
|
||||
// scrolls, older/newer chunks are rendered on demand and distant
|
||||
// messages are removed from the DOM to keep memory bounded.
|
||||
//
|
||||
// Design: sentinel-based (IntersectionObserver on top/bottom markers)
|
||||
// rather than scroll-listener based. No jank, no debounce.
|
||||
//
|
||||
// Integration: wraps UI._messageHTML() — no rendering duplication.
|
||||
// The container element (#chatMessages) is unchanged.
|
||||
//
|
||||
// Streaming: the actively-streaming message is always in DOM at the
|
||||
// bottom, managed by streamResponse(). VirtualScroll leaves it alone.
|
||||
|
||||
const WINDOW_SIZE = 80; // max messages in DOM at once
|
||||
const BUFFER = 20; // render this many beyond viewport edge
|
||||
const PREPEND_CHUNK = 40; // load this many when scrolling up
|
||||
const LOAD_MORE_THRESHOLD = 100; // only activate for conversations this long
|
||||
|
||||
class VirtualScroll {
|
||||
constructor() {
|
||||
this._messages = []; // full message array (data, not DOM)
|
||||
this._renderFn = null; // (msg, index, dimmed) => html string
|
||||
this._summaryFn = null; // (msg) => html string
|
||||
this._postRenderFn = null; // (container) => void
|
||||
this._container = null;
|
||||
this._observer = null;
|
||||
this._topSentinel = null;
|
||||
this._renderedRange = { start: 0, end: 0 }; // indices into _messages
|
||||
this._enabled = false;
|
||||
this._scrollLock = false; // prevent observer re-entry during DOM mutation
|
||||
this._summaryIdx = -1;
|
||||
}
|
||||
|
||||
// ── Configuration ────────────────────────
|
||||
|
||||
/**
|
||||
* Bind to a container and rendering functions.
|
||||
* @param {HTMLElement} container - the #chatMessages element
|
||||
* @param {Function} renderFn - (msg, index, dimmed?) => html string
|
||||
* @param {Function} summaryFn - (msg) => html string
|
||||
* @param {Function} postRenderFn - (container) => void (extensions, images)
|
||||
*/
|
||||
bind(container, renderFn, summaryFn, postRenderFn) {
|
||||
this._container = container;
|
||||
this._renderFn = renderFn;
|
||||
this._summaryFn = summaryFn;
|
||||
this._postRenderFn = postRenderFn;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────
|
||||
|
||||
/**
|
||||
* Set messages and render. Called by renderMessages().
|
||||
* For short conversations, returns false (caller should use
|
||||
* the original innerHTML path). For long ones, renders the
|
||||
* tail window and returns true.
|
||||
*/
|
||||
setMessages(messages, summaryIdx) {
|
||||
this._messages = messages;
|
||||
this._summaryIdx = summaryIdx;
|
||||
|
||||
if (!this._container || !this._renderFn) return false;
|
||||
|
||||
// Only virtualize long conversations
|
||||
const visible = messages.filter(m => m.role !== 'system');
|
||||
if (visible.length < LOAD_MORE_THRESHOLD) {
|
||||
this._teardown();
|
||||
this._enabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
this._enabled = true;
|
||||
this._renderTail();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether virtual scroll is currently active.
|
||||
*/
|
||||
get active() { return this._enabled; }
|
||||
|
||||
/**
|
||||
* Clean up observers. Call when switching conversations.
|
||||
*/
|
||||
destroy() {
|
||||
this._teardown();
|
||||
this._messages = [];
|
||||
this._enabled = false;
|
||||
}
|
||||
|
||||
// ── Internal: Render ─────────────────────
|
||||
|
||||
/**
|
||||
* Render the tail (most recent) window of messages.
|
||||
* This is the initial render — user sees the latest messages.
|
||||
*/
|
||||
_renderTail() {
|
||||
const msgs = this._filteredMessages();
|
||||
const total = msgs.length;
|
||||
const start = Math.max(0, total - WINDOW_SIZE);
|
||||
const end = total;
|
||||
|
||||
this._renderedRange = { start, end };
|
||||
this._buildDOM(msgs, start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend older messages when user scrolls to top.
|
||||
*/
|
||||
_prependChunk() {
|
||||
if (this._scrollLock) return;
|
||||
const msgs = this._filteredMessages();
|
||||
const { start } = this._renderedRange;
|
||||
if (start <= 0) return; // already showing all
|
||||
|
||||
this._scrollLock = true;
|
||||
|
||||
const newStart = Math.max(0, start - PREPEND_CHUNK);
|
||||
const container = this._container;
|
||||
|
||||
// Remember scroll position to restore after prepend
|
||||
const scrollBottom = container.scrollHeight - container.scrollTop;
|
||||
|
||||
// Build HTML for the new chunk
|
||||
let html = '';
|
||||
for (let i = newStart; i < start; i++) {
|
||||
html += this._renderOne(msgs[i], i);
|
||||
}
|
||||
|
||||
// Insert after top sentinel, before existing messages
|
||||
const sentinel = this._topSentinel;
|
||||
if (sentinel) {
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
sentinel.after(frag);
|
||||
}
|
||||
|
||||
// Trim bottom if DOM is too large
|
||||
const totalRendered = this._renderedRange.end - newStart;
|
||||
if (totalRendered > WINDOW_SIZE + BUFFER) {
|
||||
const excess = totalRendered - WINDOW_SIZE;
|
||||
this._removeFromBottom(excess);
|
||||
this._renderedRange.end -= excess;
|
||||
}
|
||||
|
||||
this._renderedRange.start = newStart;
|
||||
|
||||
// Restore scroll position (prevent jump)
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollTop = container.scrollHeight - scrollBottom;
|
||||
if (this._postRenderFn) this._postRenderFn(container);
|
||||
this._scrollLock = false;
|
||||
this._updateSentinelState();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Internal: DOM ────────────────────────
|
||||
|
||||
_buildDOM(msgs, start, end) {
|
||||
const container = this._container;
|
||||
|
||||
let html = '';
|
||||
|
||||
// Top sentinel (triggers prepend on intersection)
|
||||
html += '<div id="vs-top-sentinel" class="vs-sentinel" style="height:1px"></div>';
|
||||
|
||||
// "Load more" indicator
|
||||
if (start > 0) {
|
||||
html += '<div class="vs-load-more" id="vs-load-more" style="text-align:center;padding:12px 0">' +
|
||||
'<span style="font-size:12px;color:var(--text-3)">' +
|
||||
start + ' earlier messages</span></div>';
|
||||
}
|
||||
|
||||
// Summary boundary
|
||||
if (this._summaryIdx >= 0) {
|
||||
const summaryMsg = this._messages[this._summaryIdx];
|
||||
if (summaryMsg && this._summaryFn) {
|
||||
html += this._summaryFn(summaryMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
for (let i = start; i < end; i++) {
|
||||
html += this._renderOne(msgs[i], i);
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Set up intersection observer on top sentinel
|
||||
this._setupObserver();
|
||||
|
||||
if (this._postRenderFn) this._postRenderFn(container);
|
||||
}
|
||||
|
||||
_renderOne(msg, index) {
|
||||
if (!msg) return '';
|
||||
// Skip system messages and summary messages (summary handled separately)
|
||||
if (msg.role === 'system') return '';
|
||||
if (msg._isSummary) return '';
|
||||
const dimmed = this._summaryIdx >= 0 && index < this._summaryIdx;
|
||||
return this._renderFn(msg, index, dimmed);
|
||||
}
|
||||
|
||||
_removeFromBottom(count) {
|
||||
const container = this._container;
|
||||
const messages = container.querySelectorAll('.message');
|
||||
const toRemove = Array.from(messages).slice(-count);
|
||||
toRemove.forEach(el => el.remove());
|
||||
}
|
||||
|
||||
// ── Observer ─────────────────────────────
|
||||
|
||||
_setupObserver() {
|
||||
this._teardownObserver();
|
||||
|
||||
this._topSentinel = this._container.querySelector('#vs-top-sentinel');
|
||||
if (!this._topSentinel) return;
|
||||
|
||||
this._observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && entry.target.id === 'vs-top-sentinel') {
|
||||
this._prependChunk();
|
||||
}
|
||||
}
|
||||
}, {
|
||||
root: this._container,
|
||||
rootMargin: '200px 0px 0px 0px', // trigger 200px before reaching top
|
||||
});
|
||||
|
||||
this._observer.observe(this._topSentinel);
|
||||
}
|
||||
|
||||
_updateSentinelState() {
|
||||
const loadMore = this._container.querySelector('#vs-load-more');
|
||||
if (loadMore) {
|
||||
if (this._renderedRange.start <= 0) {
|
||||
loadMore.style.display = 'none';
|
||||
} else {
|
||||
loadMore.querySelector('span').textContent =
|
||||
this._renderedRange.start + ' earlier messages';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_teardownObserver() {
|
||||
if (this._observer) {
|
||||
this._observer.disconnect();
|
||||
this._observer = null;
|
||||
}
|
||||
this._topSentinel = null;
|
||||
}
|
||||
|
||||
_teardown() {
|
||||
this._teardownObserver();
|
||||
this._renderedRange = { start: 0, end: 0 };
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns messages with summary detection metadata.
|
||||
* Mirrors the filtering logic from renderMessages().
|
||||
*/
|
||||
_filteredMessages() {
|
||||
return this._messages.filter(m => m.role !== 'system');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Singleton ────────────────────────────
|
||||
|
||||
const virtualScroll = new VirtualScroll();
|
||||
|
||||
window.VirtualScroll = virtualScroll;
|
||||
sb.register('VirtualScroll', virtualScroll);
|
||||
Reference in New Issue
Block a user