How to Use Virtual Scrolling for Long Conversations in ChocolateLMLite

ChocolateLMLite implements virtual scrolling through sentinel-based IntersectionObserver logic in static/js/talk.js, rendering only a fixed window of messages while dynamically fetching additional history from /api/persona/active/message as users scroll.

ChocolateLMLite is a lightweight chat interface designed for extended AI conversations. As message history grows, rendering thousands of DOM nodes causes performance degradation. The application solves this by implementing virtual scrolling (infinite scroll), keeping the DOM lean through a sliding window pattern. This guide explains the mechanism using the actual source code from the gpsnmeajp/chocolatelmlite repository.

Virtual Scrolling Architecture

The system rests on three coordinated mechanisms that maintain fluid performance regardless of conversation length.

Sentinel Elements

Empty placeholder divs sit at the boundaries of the message list to detect scroll position. In static/talk.htm (lines 51–53), the markup includes:

<div class="scroll-sentinel" id="topSentinel" aria-hidden="true"></div>
<!-- messages injected here -->
<div class="scroll-sentinel" id="bottomSentinel" aria-hidden="true"></div>

These invisible sentinels trigger data fetching when they enter the viewport.

IntersectionObserver Integration

The setupScrollObservers() function (lines 2219–2249 in static/js/talk.js) creates an IntersectionObserver rooted at the chat container (#chatMessages). It watches both sentinels with a 10% visibility threshold. When topSentinel crosses this threshold, handleTopIntersection fires to load older messages; bottomSentinel visibility triggers handleBottomIntersection for newer messages.

Sliding Window State Management

Instead of storing all messages in the DOM, ChocolateLMLite maintains a sliding window of visible entries. The constants INITIAL_VISIBLE_COUNT (default 30) and VISIBLE_INCREMENT (default 10) define how many messages reside in memory and how many to fetch per scroll event. The state tracks the current start index (state.messagesStartIndex) and uses an anchor UUID to preserve visual scroll position during re-renders.

Message Loading Workflow

The virtual scroll system follows a precise sequence to maintain perceived performance:

  1. Initialization: After DOMContentLoaded, loadLatestMessages({ scrollToBottom: true }) populates the initial chunk, then setupScrollObservers() activates the sentinels.
  2. Upward Scroll: When topSentinel becomes visible, handleTopIntersection calls loadOlderMessages() (lines 2016–2044). This calculates a new start index, fetches the slice from /api/persona/active/message, prepends messages to the array, trims the tail to enforce INITIAL_VISIBLE_COUNT, and re-renders while anchoring to the previous first message.
  3. Downward Scroll: handleBottomIntersection invokes loadNewerMessages() (lines 2165–2185), which appends newer messages, slices the head if limits are exceeded, and updates state.messagesStartIndex.
  4. Anchor Preservation: During each update, the system records the UUID of the first visible message before DOM changes, then restores scroll position to that element after insertion to prevent visual jumping.
  5. Auto-Scroll: After initial load or user submission, scrollToBottom() (lines 2626–2637) ensures the view rests at the newest message.

Customizing Virtual Scroll Behavior

Adjust the sliding window parameters in static/js/talk.js before the initialization calls:

// How many messages remain in the DOM at any time
const INITIAL_VISIBLE_COUNT = 50;   // default: 30

// How many messages to fetch per sentinel trigger
const VISIBLE_INCREMENT = 20;       // default: 10

Lower values reduce memory usage but increase fetch frequency; higher values trade memory for fewer network requests.

To manually fetch a specific range for debugging or jump-to-history features, use fetchMessagesRange():

// Retrieve 100 messages before the newest entry
fetchMessagesRange(-100, 100).then(result => {
  console.log('Historical slice:', result.messages);
});

Virtual Scrolling in Game Talk Mode

The same architecture applies to the Game Talk interface via static/js/gametalk.js. This file mirrors the logic found in talk.js, including setupScrollObservers(), loadOlderMessages(), and handleBottomIntersection, ensuring consistent performance across both chat modes. Utility helpers such as renderMessages and fetchJson live in static/js/common.js and are shared between both implementations.

Summary

  • Sentinel Detection: Empty divs (#topSentinel, #bottomSentinel) in static/talk.htm act as scroll triggers via IntersectionObserver.
  • Observer Logic: setupScrollObservers() in static/js/talk.js monitors visibility at 10% threshold to initiate data fetching.
  • Windowed Rendering: Only INITIAL_VISIBLE_COUNT messages (default 30) exist in the DOM; older entries unload automatically.
  • Position Anchoring: The system uses message UUIDs to maintain scroll position during list updates, preventing visual jumps.
  • Dual Mode Support: Both standard chat (talk.js) and Game Talk (gametalk.js) implement identical virtual scroll patterns.

Frequently Asked Questions

How does ChocolateLMLite prevent scroll position jumping when loading older messages?

The application captures the UUID of the first visible message before injecting new content. After loadOlderMessages() prepends the fetched range and trims the list to INITIAL_VISIBLE_COUNT, it restores the scroll anchor to that UUID. This technique keeps the viewport visually stable despite DOM changes.

What API endpoint does the virtual scroller use to fetch message history?

The system queries /api/persona/active/message through fetchMessagesRange(), defined in static/js/talk.js (lines 2165–2185). This endpoint accepts range parameters to return specific slices of the conversation history without transmitting the entire log.

Can I disable virtual scrolling to load all messages at once?

Disabling the feature requires modifying static/js/talk.js. You would need to remove the setupScrollObservers() call and adjust loadLatestMessages() to fetch a very large range (e.g., -99999 to 99999). However, this causes severe performance degradation in long conversations as the browser renders every message node simultaneously.

Why are there two separate sentinel elements instead of one?

ChocolateLMLite uses bidirectional infinite scroll. The topSentinel detects when the user scrolls upward to request older history, while the bottomSentinel handles downward scrolling for newer messages (including real-time updates). This dual-sentinel pattern supports conversation review in both chronological directions without pre-loading the entire dataset.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →