Skip to content

fix(chat-history): scrollbar misrepresents history — MessageWindow renders no spacer for unrendered older messages (thumb shows 2.5% of a 2000-message chat) #127

Description

@jdmanring

Summary

MessageWindow.load() (static/js/chatHistory.js) renders only the newest WINDOW_SIZE (50) messages and attaches a sentinel, but creates no spacer representing the _startIdx older messages it did not render. The container's scrollHeight therefore reflects ~50 messages, not the conversation.

The scrollbar thumb is consequently huge and the track short: it tells the user the conversation is ~40× shorter than it is. Scroll position is meaningless as a progress indicator, and a drag of the thumb traverses a distance unrelated to the history.

Measured (ground truth vs. reported)

Corpus: the benchmark's deterministic 2000-message mix, 900×700 viewport.

container scrollHeight fraction of real conversation
all 2000 rendered (ground truth) 334,600 px 100%
MessageWindow at load 8,401 px 2.5%
MessageWindow scrolled to the very top 13,456 px 4.0%

Note the second row: even after paging the entire history in and pruning behind itself, the bar never approaches honesty, because pruned messages are only partially accounted.

Cause

load() sets _startIdx = max(0, messages.length - WINDOW_SIZE), calls _renderTail() and _attachSentinel(), then snaps to bottom. Nothing represents messages [0, _startIdx) in the layout. Spacers exist in this file (.chat-history-spacer, created by _pruneTop/_pruneBottom) but only for nodes evicted after being rendered — never for nodes never rendered at all.

Why this matters beyond cosmetics

  • The scroll position is the user's only sense of "where am I in this conversation."
  • It also distorts any pixel-based scroll logic: a fixed pixel scroll traverses wildly different numbers of messages under MessageWindow than under a fully-rendered list. This was hit directly while benchmarking (excursions had to be redriven in messages rather than pixels; see docs/dev/chat-history-benchmark.md).

Suggested direction (not prescriptive — read the geometry first)

Give load() a top spacer sized estimated_height * _startIdx, and refine the estimate as real heights are measured during paging. The estimator is the hard part and it is easy to make it drift; a naive per-message average taken from the newest 50 messages is not representative of the corpus. A related drift bug was found in the benchmark's own hybrid arm (#126) — read that before implementing.

Prerequisite: decide whether an approximate-but-nonzero bar is better than today's honestly-tiny one. It is, but the estimator must not make the thumb jump while scrolling.

Repro

/tmp/.../probe_spacer.py in the benchmark harness style:

import sys; sys.path.insert(0,'tests/bench')
from playwright.sync_api import sync_playwright
import chat_history_bench as B
N = 2000
with sync_playwright() as pw:
    br = pw.chromium.launch(headless=True)
    p = br.new_page(viewport=B.VIEWPORT); p.set_content(B._HARNESS_HTML)
    B.load_arm(p, "naive", N)
    truth = p.evaluate("()=>document.getElementById('chat-history').scrollHeight")
    p2 = br.new_page(viewport=B.VIEWPORT); p2.set_content(B._HARNESS_HTML)
    B.load_arm(p2, "evict", N)
    got = p2.evaluate("()=>document.getElementById('chat-history').scrollHeight")
    print(truth, got, f"{got/truth:.1%}")   # 334600 8401 2.5%
    br.close()

Provenance

Found incidentally while building the chat-history virtualization benchmark; not a regression, present since MessageWindow landed. Verified against ground truth (a fully-rendered list of the identical corpus) rather than inferred from the benchmark's spacer arithmetic.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingupstream-candidateBug or feature suitable for contributing to upstream

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions