perf(panel): draw only the agent transcript's rows near the view
PR 3a of the performance rollout, built on #4404 (2b, now merged). The agent transcript draws only what is on screen, and a page landing no longer blocks the window or redraws the task page. It also covers Charles's "nothing jumps while it loads" list.
Numbers
Mounting the agent tab over its first page in jsdom (the same harness as the tests, base vs this branch):
| Messages | Before | After |
|---|---|---|
| 2,000 | 4.0 s, 42,052 DOM nodes, 2,000 rows | 130 ms, 257 nodes, 9 rows |
| 200 | 558 ms, 3,854 nodes | 189 ms, 239 nodes |
transcript.first_paint and transcript.caught_up are unchanged and will show the effect on the Mac in Sentry.
The dependency: @tanstack/react-virtual 3.14.21
This is @luke/ui's first virtualizer. It is pinned exactly and sits behind its own subpath (@luke/ui/virtual-rows), out of the barrel, as recharts is.
- Why a library rather than writing one: a virtual list that anchors to its end has to measure dynamic heights, keep the reader's place on a prepend, and correct for rows measured above the view. All of that interacts with browser scroll events, and it is the hard part. TanStack 3.14 does all of it (
anchorTo: "end", sizes cached bygetItemKey). It does not adjust the scroll when a row that only spans the top of the view is measured again, so a fold opening never moves the rows above it. - Why not
virtua: it caches sizes by index rather than by key, and it owns the scroller and the row wrappers. TanStack is headless, soconversation.tsxkeeps its own "follow the newest line" logic and its scroll box. - Size: ~64 kB unpacked across react-virtual and virtual-core. No transitive dependencies.
- Upgrading: bump the exact version in
packages/ui/package.json, then runagent-tab-virtual.test.tsandconversation.test.ts.
What changed
- Virtualized log:
ConversationRows(ai-elements/conversation.tsx) wrapsVirtualRows. Each row is one placed message with what stands with it (call dividers, the voice lines said ahead of it, the replay card), keyed by message id. The working line, end line and error stay after the run.Conversationreads the run'sdata-virtual-rowsto keep following the newest line. It leaves the place-keeping on a prepend to the virtualizer, so 2b'sloadOlder/onNearTopkeep working.- A row hidden in a kept tab (
display: none) is never measured as 0, and the view never shrinks to 0, so showing a tab again re-parses nothing.
- Find-in-tab (
tasks/transcript-find.ts): the transcript hands its scope aFindSource, the waywhole-file.tsxdoes.- A drawn row is counted in the DOM, and that count is kept while the query stands. A row never drawn is counted from its messages' text and reasoning.
- A step to a match in an undrawn row scrolls the virtualizer to it, then paints the match and centres it once the row is drawn.
- Message links:
useMessageRevealbrings a held but undrawn message's row into view before lighting it, waits for a held message the log hasn't drawn yet, and answers each ask once. - Older pages in a transition:
use-agent-transcript.ts'sloadOldercommits insidestartTransition. - No whole-page redraw per page:
- Held transcripts:
useTabViewskeeps them in maps owned by the task page rather than in its state. Hover cards read them lazily as they are hung.heldonly changes identity when a title it names changes, because the subagent tab labels read titles while drawing. - Stable
LiveDoors:taskCards().agent(id)returns the same object per agent, so theLiveDoorsvalue no longer changes per page.
- Held transcripts:
- Append-only work:
- Arrivals and call-places rows are reused for each message object unless its lines changed.
- The spoken-line Schema decode is cached for each message.
newestTodosreads backwards from the newest message.callPlaces(voice) stops spreading an array on every line, which was quadratic.- What is left per page is a linear pass of map lookups over message ids.
- Streamdown:
TranscriptWordstakesstreamingand passesmode="streaming"for a message still being written. Settled messages staystatic. The mergedcomponents,allowedTagsandliteralTagContentare memoized on the element's components, tag and attributes.
Nothing jumps while it loads (Charles, Oct 21)
- Estimate → measure: with
anchorTo: "end", a newly measured row is compensated only when it starts above the view. A row measured again is compensated only when it is wholly above the view. The virtualizer never sticks to the end on its own (scrollEndThreshold: -1), so only the log's own following moves the bottom. - Footer docks: the log's first draw waits for the pull request read, or uses the pull request the task already knows (
control.pullRequests), soPublishedDockis never added under lines already drawn. A refused read also counts as answered, so the log never waits forever. The todos dock and the usage/context ring come from the same page as the messages. The ring sits in the composer's single row and changes no height. - Call rows: if any held line was said on a call, the first draw also waits for the calls read (
useAgentCallsnow reportssettled). After that, dividers never land between rows already drawn. - Code colouring: I checked
transcript-markdown.tsx. The plain and coloured blocks have the same DOM (header,pre,code), the same lines and the same metrics. Onlycolordiffers, so there is nothing to change. - Opening a fold: besides (1), clicking a
summaryin the log takes it off following. The fold then grows under the reader instead of scrolling itself and the lines above it away.
Tests
tasks/agent-tab-virtual.test.ts:
- A 2,000-message transcript draws at most 20 rows, the newest among them.
- ⌘F counts a match in an undrawn row, scrolls the log to it, and lights it there.
- A page landing doesn't redraw an unchanged message. This is a render count of a component inside an older row's hover card, through the real
useTabViews+taskCardswiring. It fails on the base. - A prepend of 10 older rows moves the scroll by exactly their height, so the reader's row stays put.
- The log first draws only once the pull request is read, with the dock already present.
- The log first draws only once the calls read answered, when its lines were said on a call.
conversation.test.ts: a fold the reader opens on the newest line grows without the log following it.
agent-senders.test.ts now lays out a tall window, because it reads every line from the DOM and jsdom lays nothing out.
Not checked
- By hand on the Mac: the sandbox can't run the desktop app, so the live scroll behaviour with a real long transcript is untested.
- Measured row heights: jsdom can't lay out rows, so no test covers a row measuring taller than its estimate. That behaviour is TanStack's documented default predicate.
Out of scope, as briefed: choosing the agent tab from the task row before the agent list returns, server changes, the AppWindow redraw, and the task-page placeholder chain.
Made with Luke