// ========================================== // 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 += '
'; // "Load more" indicator if (start > 0) { html += '