Skip to content

perf(chat): build a folded turn only when it is opened (#81) - #90

Open
dnviti wants to merge 1 commit into
chore/version-bump-5.3.2from
feat/issue-81-lazy-folded-turns
Open

perf(chat): build a folded turn only when it is opened (#81)#90
dnviti wants to merge 1 commit into
chore/version-bump-5.3.2from
feat/issue-81-lazy-folded-turns

Conversation

@dnviti

@dnviti dnviti commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Closes #81.

The problem, measured

Every turn but the newest is folded shut, and the list rendered every one of
them anyway and set display: none on the folded ones. So entering a
conversation built its entire backlog for nobody to look at — and because the
chat surface is remounted per session (key={chatController.sessionId}), that
was paid on every entry, not just the first.

With the fix removed, the browser suite measures it directly:

FAIL :: with history folded, the browser has not built those turns :: 80 message bubbles mounted for a 40-turn conversation
FAIL :: so entering the tab costs a strip per turn, not that turn's contents :: 76.0 DOM nodes per folded turn
FAIL :: and it does not follow how heavy the folded turns are :: 134.0 nodes per folded turn at 60k characters against 76.0 at 2k

What changes

A folded turn is not built until it is opened. Forty turns mount 2 bubbles
instead of 80, and cost 28 DOM nodes per folded turn — a strip and an index
row — the same figure whether the turn holds 2k or 60k characters.

What has been opened is kept, so re-folding and re-opening is immediate rather
than a second rebuild. That is exactly what hiding-rather-than-unmounting used
to buy, and it is asserted by stamping the element and checking the same one
comes back.

What is kept is bounded by content, not by a turn count — the new
src/client/chat/retain.ts. Open turns and the live turn are outside the
budget entirely; behind them a floor of turns is kept whatever they weigh, then
turns are kept until 200k characters of content is spent, least recently opened
released first.

Nothing is made unreachable: every turn keeps its strip and its index row, and
a jump from the index or from search opens and builds the turn as part of the
jump. scrollToId retries once on the next frame, because a turn being jumped
to may only just have been built.

The open questions

  • Fixed count or scaled by weight? Weight. The goal is bounded memory and a
    turn count does not bound it — the issue says so itself.
  • Tabs never visited? Nothing to do, and that is a measurement rather than a
    judgement: AppShell mounts exactly one ChatView, keyed by session, so an
    unvisited conversation prepares nothing because it is not mounted at all.

One acceptance criterion not met literally

Re-folding and re-opening a turn, and leaving and re-entering the tab, do
not repeat the preparation cost for turns still within the kept limit.

Re-folding and re-opening is met. Leaving and re-entering cannot be: the
surface is deliberately remounted per session and a remount throws the DOM away
by definition — and keeping every conversation's tree mounted is the
compounding cost the issue complains about in its own motivation. What re-entry
costs instead is one turn's worth of content whatever the history length, which
is the goal that criterion serves. Flagged rather than quietly skipped.

Verification

  • npm run typecheck clean
  • 1602 unit tests passing (14 new, over retain and the weight function)
  • 378 browser checks, 0 FAIL (baseline 366 on this branch)
  • Control test with MessageList.tsx stashed: 5 FAIL, listed above. The
    four checks that guard what must survive pass on the control too — they are
    regression guards, not claims about the fix.

One check could not have failed and the control test caught it: measuring a
heavy turn as a single 60,000-character paragraph renders the same number of
elements as a 2,000-character one, so the comparison read 1353 against 1353
and passed with the fix removed. The fixture now splits content into 2k blocks.

🤖 Generated with Claude Code

Entering a conversation rebuilt its whole backlog at once — text, code
blocks, diagrams, tool output — as a visible stutter that grew with the
history and was paid on every entry, because the chat surface is
remounted per session. Almost none of it was on screen: every turn but
the newest is folded shut, and the list rendered every turn and then hid
the folded ones.

A folded turn is now not built at all. Forty turns mount the two bubbles
of the turn on screen rather than eighty, and cost a strip and an index
row each — twenty-eight DOM nodes, the same whether the turn holds two
thousand characters or sixty thousand.

What has been opened is kept, so re-folding and re-opening is immediate
rather than a second rebuild. What is kept is bounded by the content it
holds rather than by a count of turns, since a conversation of one-line
exchanges and one full of large files are nothing alike at the same turn
count; past the bound the least recently opened material is released and
built again on demand.

The turn in progress is exempt — prepared folded or open — and a turn
that kept running while folded opens on its current state, because a
bubble reads its message off the transcript when it mounts.

Closes #81

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 17:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves chat transcript performance by avoiding rendering (“building”) folded turns until they’re opened, while retaining a bounded amount of previously opened content to keep re-open interactions fast.

Changes:

  • Add a content-budgeted retention policy (retain) and a weight estimator (messageWeight) to decide which folded turns stay mounted.
  • Update MessageList to mount bubbles only for retained turns, with a scroll retry to support “open then jump” flows.
  • Add unit + browser coverage for retention policy, deferred mounting, bounded retention, and jump-to-released-turn behavior; document the behavior in docs and changelog.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/chat-retain.test.js New unit tests for retention policy ordering/budgeting and message weight calculation.
test/browser/checks.ts New DOM-level browser check asserting folded turns aren’t built, retention is bounded, and jumps open released turns.
src/client/shell/chat/MessageList.tsx Apply retention policy to conditional bubble mounting; add scroll retry for open-then-jump.
src/client/chat/retain.ts New retention policy module + message weight estimator for budgeting retained folded content.
docs/runtimes.md Document “long conversation” behavior (deferred mounting + bounded retention).
CHANGELOG.md Add unreleased entry describing the folded-history deferred build and bounded retention change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +303 to +326
const weights = React.useRef(new Map<string, number>());
// Indexed rather than searched: this runs on every render, and a `find`
// per candidate turn is quadratic in a conversation long enough for any of
// this to matter.
const turnById = React.useMemo(() => {
const map = new Map<string, TurnSummary>();
for (const turn of turns) map.set(turn.id, turn);
return map;
}, [turns]);
const turnIds = React.useMemo(() => turns.map((turn) => turn.id), [turns]);
const weightOf = React.useCallback(
(turnId: string) => {
const turn = turnById.get(turnId);
if (!turn) return 0;
const key = `${turnId}:${turn.messageIds.length}`;
const cached = weights.current.get(key);
if (cached !== undefined) return cached;
let weight = 0;
for (const id of turn.messageIds) {
const message = messageById.get(id);
if (message) weight += messageWeight(message);
}
weights.current.set(key, weight);
return weight;
Comment thread src/client/chat/retain.ts
Comment on lines +32 to +36
* How much content may sit in released-but-prepared turns, in weight units.
*
* A unit is roughly one character of rendered content (see `turnWeight`), so
* this is about 200k characters of backlog held against a re-open. Turns that
* are open, and the live one, are outside the budget entirely.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants