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-105

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 (SteadyAgentMessage and sameMessageDraw in agent-tab.tsx). A message re-renders only when the message object, its position, or its doors change. applyMessagesPage already keeps unchanged messages as the same objects.
    • The window rebuilds cards and chat on every render, so they no longer travel inside each message's props. They go through a LiveDoors context 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 onOpen and onOpenTarget handlers keep the same identity across renders (useSteady).
  • 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 ForkDoors object doesn't re-render every message.
  • agentParts is cached per message object in a WeakMap. Messages are never changed in place.
  • MarkdownMessage is 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 through AgentTab. 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 of LiveDoors.
  • 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