perf(desktop): a page a running agent's tab hears redraws the message it touched, not the whole transcript and plan
Closed
CH
8d ago+640+640+012345678901234567890123456789-105-105-012345678901234567890123456789
Stacked on #3726. This is the larger part of the Neon Realtime lag Charles reported: the task's window sat at about 1.1 GB and used 10–30% CPU while idle.
Cause
While an agent is writing, its tab receives a transcript page up to twice a second. Each page touches only the reply still being written, but every page:
- re-rendered every message in the transcript and re-parsed every message's parts;
- re-rendered the task's page (through
onHeld), and that re-parsed every section of the plan with react-markdown, even while the plan tab was hidden.
Change
- Transcript messages are memoized (
SteadyAgentMessageandsameMessageDrawinagent-tab.tsx). A message re-renders only when the message object, its position, or its doors change.applyMessagesPagealready keeps unchanged messages as the same objects.- The window rebuilds
cardsandchaton every render, so they no longer travel inside each message's props. They go through aLiveDoorscontext that only the rows that use them read: delegation rows, target rows and chat notices. A memoized message therefore still shows the current cards. - The
onOpenandonOpenTargethandlers keep the same identity across renders (useSteady).
- The window rebuilds
- Context values stay stable while their content is unchanged: the message place, the Mac owner, the mentioned servers, and the steering and undelivered lines. Whether the tab offers Fork now has its own boolean context, so a new
ForkDoorsobject doesn't re-render every message. agentPartsis cached per message object in aWeakMap. Messages are never changed in place.MarkdownMessageis memoized. The plan keeps one stable task toggler per section, which reads the plan as it is at the moment of the press. A section whose text didn't change isn't parsed again.
Measured
Benchmark in the test DOM (jsdom, React dev build): 300-message transcript, 20 pages each updating the newest reply.
- Per page: about 265 ms before, about 77 ms after. With 10 messages it is about 31 ms, so most of what is left is fixed cost.
- Re-rendering the agent tab from its parent with new cards, presses and fork doors now re-renders no messages.
The benchmark isn't committed. I haven't measured on Charles's Mac yet.
Tests
- New
agent-tab-kept.test.ts, driven throughAgentTab. A message that no page changed shows the current card after the window re-renders with a renamed agent. Its worker's row also reflects the worker's status from the newest page. The test fails if rows read cards from the memoized props instead ofLiveDoors. plan-document-editor.test.ts: ticking a box in a section whose text didn't change, after another section changed, saves against the current plan. The test fails if the toggler holds on to the first plan.vitest run apps/desktop/src/renderer/tasks apps/desktop/src/renderer/desktop: 1595 tests pass. Lint, typecheck, knip and discarded-effect also pass.
Not done
- Passing the held transcript to the task page on every page (
onHeld). The task page still re-renders, but what that re-render triggered (the messages and the plan) is now skipped. Moving the held transcripts into a store would touch about ten files, and I'd rather measure on the real task first. - Memory. Every transcript opened stays in memory while the task is open. The transcript is also not virtualized.
Made with Luke