perf(chat): build a folded turn only when it is opened (#81) - #90
Open
dnviti wants to merge 1 commit into
Open
Conversation
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>
Contributor
There was a problem hiding this comment.
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
MessageListto 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 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: noneon the folded ones. So entering aconversation built its entire backlog for nobody to look at — and because the
chat surface is remounted per session (
key={chatController.sessionId}), thatwas paid on every entry, not just the first.
With the fix removed, the browser suite measures it directly:
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 thebudget 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.
scrollToIdretries once on the next frame, because a turn being jumpedto may only just have been built.
The open questions
turn count does not bound it — the issue says so itself.
judgement:
AppShellmounts exactly oneChatView, keyed by session, so anunvisited conversation prepares nothing because it is not mounted at all.
One acceptance criterion not met literally
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 typecheckcleanretainand the weight function)MessageList.tsxstashed: 5 FAIL, listed above. Thefour 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 1353and passed with the fix removed. The fixture now splits content into 2k blocks.
🤖 Generated with Claude Code