perf(panel): draw only the agent transcript's rows near the view

Merged
CH
4d ago
+1620-193

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):

MessagesBeforeAfter
2,0004.0 s, 42,052 DOM nodes, 2,000 rows130 ms, 257 nodes, 9 rows
200558 ms, 3,854 nodes189 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 by getItemKey). 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, so conversation.tsx keeps 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 run agent-tab-virtual.test.ts and conversation.test.ts.

What changed

  • Virtualized log: ConversationRows (ai-elements/conversation.tsx) wraps VirtualRows. 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.
    • Conversation reads the run's data-virtual-rows to keep following the newest line. It leaves the place-keeping on a prepend to the virtualizer, so 2b's loadOlder / onNearTop keep 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 a FindSource, the way whole-file.tsx does.
    • 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: useMessageReveal brings 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's loadOlder commits inside startTransition.
  • No whole-page redraw per page:
    • Held transcripts: useTabViews keeps them in maps owned by the task page rather than in its state. Hover cards read them lazily as they are hung. held only 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 the LiveDoors value no longer changes per page.
  • 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.
    • newestTodos reads 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: TranscriptWords takes streaming and passes mode="streaming" for a message still being written. Settled messages stay static. The merged components, allowedTags and literalTagContent are memoized on the element's components, tag and attributes.

Nothing jumps while it loads (Charles, Oct 21)

  1. 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.
  2. Footer docks: the log's first draw waits for the pull request read, or uses the pull request the task already knows (control.pullRequests), so PublishedDock is 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.
  3. Call rows: if any held line was said on a call, the first draw also waits for the calls read (useAgentCalls now reports settled). After that, dividers never land between rows already drawn.
  4. 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. Only color differs, so there is nothing to change.
  5. Opening a fold: besides (1), clicking a summary in 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 + taskCards wiring. 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


Devin Review