278 lines
10 KiB
JavaScript
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);
|