This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/virtual-scroll.js
2026-03-15 01:33:38 +00:00

278 lines
10 KiB
JavaScript

// ==========================================
// 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);