# How to Use Virtual Scrolling for Long Conversations in ChocolateLMLite

> Discover how ChocolateLMLite uses virtual scrolling for long conversations. Learn how it efficiently renders messages and fetches history dynamically for a smooth user experience.

- Repository: [Segment (gpsnmeajp)/chocolatelmlite](https://github.com/gpsnmeajp/chocolatelmlite)
- Tags: how-to-guide
- Published: 2026-03-02

---

**ChocolateLMLite implements virtual scrolling through sentinel-based IntersectionObserver logic in [`static/js/talk.js`](https://github.com/gpsnmeajp/chocolatelmlite/blob/main/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:

```html
<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`](https://github.com/gpsnmeajp/chocolatelmlite/blob/main/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`](https://github.com/gpsnmeajp/chocolatelmlite/blob/main/static/js/talk.js) before the initialization calls:

```javascript
// 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()`:

```javascript
// 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`](https://github.com/gpsnmeajp/chocolatelmlite/blob/main/static/js/gametalk.js). This file mirrors the logic found in [`talk.js`](https://github.com/gpsnmeajp/chocolatelmlite/blob/main/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`](https://github.com/gpsnmeajp/chocolatelmlite/blob/main/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`](https://github.com/gpsnmeajp/chocolatelmlite/blob/main/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`](https://github.com/gpsnmeajp/chocolatelmlite/blob/main/talk.js)) and Game Talk ([`gametalk.js`](https://github.com/gpsnmeajp/chocolatelmlite/blob/main/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`](https://github.com/gpsnmeajp/chocolatelmlite/blob/main/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`](https://github.com/gpsnmeajp/chocolatelmlite/blob/main/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.