18 KiB
AgentGUI TanStack Virtual Upgrade Plan
Status: implemented on 2026-07-25
Goal
Reduce AgentGUI transcript scroll and layout work by upgrading TanStack Virtual and letting it own the chat-list mechanics it already implements.
Keep Turn-level virtualization in the first implementation. Row-level virtualization is a later step only if traces still show that one oversized active Turn is the main bottleneck.
Current evidence
@tutti-os/agent-guideclares@tanstack/react-virtual: ^3.13.12.- The lockfile currently resolves
@tanstack/react-virtual3.13.26 and@tanstack/virtual-core3.16.0. - The current virtualizer counts transcript Turn groups, uses stable Turn-group keys, dynamically measures mounted groups, anchors to the end outside Turn disclosure motion, and uses six Turn groups of overscan.
followOnAppend,isAtEnd,getDistanceFromEnd,scrollToEnd,scrollToIndex,rangeExtractor,initialMeasurementsCache, andtakeSnapshotalready exist in the resolved core version.AgentTranscriptViewdoes not enablefollowOnAppend.useAgentGUIDetailScrollseparately owns bottom following, content-resize bottom correction, andscrollHeight-delta prepend compensation.- The older-message loading indicator is a normal-flow sibling before
AgentConversationFlow. While it is visible, the virtual list does not start at the scroll element's origin. - The transcript virtualizer and
useAgentGUIDetailScrollcan therefore react to the same content-size change. This split ownership is the main simplification target. - The latest packages checked on 2026-07-25 are
@tanstack/react-virtual3.14.8 and@tanstack/virtual-core3.17.6. React Virtual 3.14.8 depends on Virtual Core 3.17.6. - The newer releases add eager prepend reconciliation, safer dynamic-size
adjustment, cached remeasurement, iOS momentum handling, and React
directDomUpdates.
The original Browser-behind-AgentGUI visual tearing report is not a proven TanStack bug. Renderer pressure and Electron webview stacking remain separate candidate mechanisms.
Decisions
1. Upgrade before changing virtualization granularity
Upgrade:
@tanstack/react-virtual 3.13.26 -> 3.14.8
@tanstack/virtual-core 3.16.0 -> 3.17.6 (transitive)
Update the manifest minimum to ^3.14.8 so the declared contract includes the
React APIs used by the implementation.
Do not add a direct @tanstack/virtual-core dependency. AgentGUI consumes the
React adapter, and its exact core dependency supplies the matching core.
2. Keep Turn-level virtual items initially
The first implementation keeps:
virtual item = presentation Turn group + its Turn attachments
This preserves:
- completed Turn disclosure and auto-collapse;
- canonical Turn versus presentation-group separation;
goal-controlrows withturnId: null;- current locator and attachment group indexes;
- current spacing, dividers, participant headers, and CSS grouping.
The change reduces scroll coordination and React/layout overhead. It does not bound the DOM inside one mounted oversized running Turn.
3. Give each scroll behavior one owner
When Turn virtualization is active, TanStack owns item-list geometry changes.
When virtualization is inactive, useAgentGUIDetailScroll keeps the current
native DOM behavior.
| Behavior | Virtualized owner | Non-virtual owner |
|---|---|---|
| New Turn appended while already at the end | followOnAppend |
useAgentGUIDetailScroll |
| Last Turn grows while streaming | anchorTo: "end" plus measureElement |
content ResizeObserver |
| Older Turns prepended | stable keys, scrollMargin, and anchorTo: "end" |
scrollHeight delta |
| Determine whether latest content is visible | isAtEnd() |
DOM geometry |
| User requests “scroll to bottom” | scrollToEnd() |
native smooth scroll |
| Locate an offscreen Turn or attachment | scrollToIndex() |
DOM lookup |
| Trigger older-history loading near the top | AgentGUI | AgentGUI |
| Detect wheel, keyboard, or pointer intent | AgentGUI | AgentGUI |
| Conversation selection and identity fencing | AgentGUI | AgentGUI |
| Bottom-dock safe-area measurement | AgentGUI | AgentGUI |
AgentGUI must not apply its manual prepend delta or content-resize bottom write after TanStack has accepted ownership of the virtualized branch.
4. Expose a narrow virtual-scroll controller
AgentTranscriptView owns the TanStack instance. The detail pane still owns
the product-level scroll button, loading trigger, conversation switch, and
native non-virtual branch.
Pass a ref through:
AgentGUIDetailPane
-> AgentGUIDetailTimeline
-> AgentGUIConversationTimelinePane
-> AgentConversationFlow
-> AgentTranscriptView
The ref exposes only presentation scrolling:
interface AgentTranscriptVirtualScrollController {
agentSessionId: string;
enabled: boolean;
isAtEnd(): boolean;
scrollToEnd(options?: { behavior?: ScrollBehavior }): void;
}
scrollToIndex remains internal to AgentTranscriptView, because locator and
attachment indexes belong to the transcript projection.
Every controller use must check the exact agentSessionId. A controller from
the previous Session must not move the newly selected timeline.
TanStack measurement keys must also include
conversation.sourceDetail.session.agentSessionId. A Turn-group key is stable
inside one Session, but it is not a repository-wide Session identity. Prefixing
it prevents one Session from reading another Session's cached height.
5. Use the chat parameters deliberately
Target virtualizer configuration:
useVirtualizer({
count: turnGroups.length,
getScrollElement: () => virtualScrollElement,
getItemKey: (index) =>
`${agentSessionId}\u0000${turnGroups[index]?.key ?? index}`,
estimateSize: () => AGENT_TRANSCRIPT_ESTIMATED_TURN_HEIGHT_PX,
overscan: AGENT_TRANSCRIPT_VIRTUALIZATION_OVERSCAN,
scrollMargin: virtualListOffsetFromScrollOrigin,
anchorTo: hasMovingTurnDisclosure ? "start" : "end",
followOnAppend: hasMovingTurnDisclosure ? false : true,
scrollEndThreshold: 24,
directDomUpdates: true,
directDomUpdatesMode: "transform",
useFlushSync: true
});
Rules:
followOnAppendhandles only a count increase with a new last key while the previous list was at the end.- Memoize the Session-prefixed
getItemKeycallback so unrelated renders do not invalidate measurement work. - Measure
scrollMarginfrom the timeline scroll origin to the virtual container. Recompute it when the older-message loading indicator appears or disappears. Direct DOM transform mode subtracts this option when positioning items. - Before direct DOM updates are enabled, AgentGUI's JSX transform must subtract
the same
scrollMargin. Task 3 removes that JSX transform when TanStack takes over item positioning. - Do not remove manual prepend compensation until the measured
scrollMarginpath is active. Otherwise the external loading indicator can move every rendered Turn without TanStack knowing that the list origin moved. - Streaming growth of the existing last Turn is handled by end anchoring and
measurement, not
followOnAppend. - During disclosure motion, keep start anchoring, disable append following, keep the existing pinned-row behavior, and suppress TanStack size-change correction.
- Keep
useFlushSync: truefor the first implementation. TestuseFlushSync: falseseparately only if a trace still identifies synchronous React commits as a material cost. - Do not enable
useAnimationFrameWithResizeObserverwithout trace evidence; it intentionally delays measurement by one frame. - Do not enable
useCachedMeasurementsunless AgentGUI keeps the same transcript mounted but CSS-hidden. Conversation replacement is not that case. - Do not add snapshot restoration. The current product contract opens a newly selected conversation at the latest content.
- Do not add a custom
rangeExtractoruntil a real offscreen item must remain mounted.
6. Use direct DOM updates only with its required markup
With directDomUpdates: true:
- attach
rowVirtualizer.containerRefto the inner virtual-size container; - remove its JSX
height: rowVirtualizer.getTotalSize()value; - keep each virtual item
position: absolute,top: 0, andleft: 0; - remove each item's JSX main-axis
transform; - keep
measureElementanddata-index; - keep cross-axis width and Turn-specific bottom padding in AgentGUI.
The existing CSS already satisfies the absolute-position requirements.
Transform mode creates a stacking context and usually a compositor layer per item. Because the reported visual symptom involves an Electron webview, landing this option requires both trace evidence and a real-pixel/webview-order check. If transform mode reproduces the visual fault, compare the upgraded virtualizer without direct DOM updates before considering position mode. Do not ship an unmeasured mode switch.
What stays in AgentGUI
TanStack does not understand AgentGUI domain semantics. These remain application-owned:
- transcript row and Turn-group projection;
- canonical Turn identity and presentation grouping;
goal-controlrows without Turn ownership;- Turn disclosure and completion-time collapse;
- user-message locator and Turn-attachment index mapping;
- exact Session identity and stale-controller fencing;
- older-page request state and top-prefetch policy;
- participant headers, dividers, spacing, and attachment placement;
- tool-group disclosure and its inner DOM;
- Browser webview visibility, stacking, and pixel correctness.
Implementation tasks
Task 1: Upgrade and lock the dependency
Files:
packages/agent/gui/package.jsonpnpm-lock.yaml
Steps:
- Update
@tanstack/react-virtualto^3.14.8. - Install with the repository package manager.
- Confirm the lockfile resolves React Virtual 3.14.8 and Core 3.17.6.
- Run the focused AgentGUI typecheck before behavior changes.
Task 2: Add the virtual-scroll controller and behavior delegation
Files:
packages/agent/gui/shared/agentConversation/components/AgentTranscriptView.tsxpackages/agent/gui/shared/agentConversation/components/AgentConversationFlow.tsxpackages/agent/gui/agent-gui/agentGuiNode/view/AgentGUIConversationTimelinePane.tsxpackages/agent/gui/agent-gui/agentGuiNode/view/AgentGUIDetailTimeline.tsxpackages/agent/gui/agent-gui/agentGuiNode/view/AgentGUIDetailPane.tsxpackages/agent/gui/agent-gui/agentGuiNode/view/useAgentGUIDetailScroll.ts
Steps:
- Add the exact-conversation virtual-scroll controller.
- Prefix TanStack item keys with the exact Agent Session id.
- Pass older-message loading state as a virtual-list layout revision and
measure the list's
scrollMargin. - Subtract
scrollMarginin the existing JSX transform. - Enable
followOnAppendoutside disclosure motion. - Route virtualized at-end reads and explicit bottom requests through TanStack.
- Disable the outer content-resize bottom write for virtualized transcripts.
- Disable manual prepend-delta restoration for virtualized transcripts only
after
scrollMarginis active. - Preserve the native branch unchanged for short transcripts.
Task 3: Enable direct DOM updates
Files:
packages/agent/gui/shared/agentConversation/components/AgentTranscriptView.tsxpackages/agent/gui/app/renderer/agentactivity.css
Steps:
- Attach
containerRef. - Remove React-owned virtual container height and item transform.
- Keep disclosure spacing, measurement, and locator attributes.
- Confirm the option remains fixed for the virtualizer's mount lifetime.
Task 4: Add and update behavior coverage
Files:
packages/agent/gui/shared/agentConversation/components/AgentTranscriptView.virtual.spec.tsxpackages/agent/gui/agent-gui/agentGuiNode/view/useAgentGUIDetailScroll.spec.tsx- relevant detail timeline render-budget specs
Required cases:
- A new Turn follows only when the previous viewport was at the end.
- Streaming growth remains bottom-locked at the end.
- Wheel, keyboard, and pointer scroll-away intent prevents following.
- A submitted prompt explicitly returns to the end.
- Older-history prepend preserves the visible Turn without a manual DOM delta.
- Showing and hiding the older-message loading indicator does not move the visible Turn.
- Disclosure motion keeps its pinned position and does not follow appends.
- The scroll-to-bottom button uses TanStack only for the matching Session.
- Switching Sessions cannot reuse the previous Session's measurement keys.
- Locator and attachment scrolling still reach offscreen Turn groups.
- Direct DOM mode owns container height and item transforms.
Task 5: Capture before/after performance evidence
Use the same database snapshot and viewport for both captures.
Run:
virtualized-streaming;virtualized-scroll-locator;- repeated long/short Session switching;
- an oversized live-Turn case matching approximately 19 Turns, 250 tool calls, and at least 40 tool calls in the active Turn.
If the oversized live-Turn case becomes a named performance scenario, add it to
tools/scripts/agent-gui-layout-performance-scenarios.mjs, its registry tests,
and docs/conventions/testing.md.
Compare:
- React commit and render work;
LayoutandUpdateLayoutTree;- scroll event duration and long tasks;
- mounted Turn and row counts;
- viewport jumps during prepend, append, disclosure, and Session switch.
Also reproduce with a Browser node mounted behind AgentGUI and inspect real pixels. DOM count or trace improvement alone does not prove the original visual tearing is fixed.
Acceptance gates
The first implementation may land when:
- all required behavior cases pass;
- virtualized prepend and loading-indicator transitions have no manual
scrollHeightcompensation; - a user reading history is never pulled to the end by streaming or append;
- Session switching never shows or scrolls the previous Session;
- existing disclosure, locator, and attachment behavior is unchanged;
virtualized-streamingandvirtualized-scroll-locatordo not regress;- the chosen direct DOM mode improves or preserves measured trace results;
- Browser-behind-AgentGUI pixel validation shows no new stacking fault.
If traces still show one oversized active Turn dominating render or DOM work, continue with stable render-unit virtualization. Do not use the current complexity score as a split threshold: it can be high even when the expensive content is one indivisible tool-group row.
Implementation evidence
- The manifest resolves React Virtual 3.14.8 and Virtual Core 3.17.6.
- AgentGUI package typecheck and all 2,341 package tests pass.
virtualized-scroll-locatorpasses all 24 gates. The locator index moves monotonically from 14 to 0 with zero reverse or return transitions; measuredLayoutis 31.58 ms andUpdateLayoutTreeis 348.77 ms.virtualized-streamingremains within the same observed range as the source snapshot: 2,258.26 ms after the change versus 2,212 ms before it. This is preservation evidence, not a broad speedup claim.virtualized-session-cyclepasses two long/short round trips and observesnative → virtual → native → virtual.virtualized-oversized-active-turnreaches 19 Turns and 250 tool calls while keeping only 9 virtual Turns mounted. Its 1,118AgentGUINodemarkers show that stable render-unit virtualization has real evidence for a separate follow-up, but it remains outside this upgrade.browser-behind-agent-gui-pixelscaptures the high-contrast Browser fixture first, then the fullscreen AgentGUI covering that still-mounted webview. The composited screenshot has no visible fixture-color leakage.
Deferred render-unit virtualization
The deferred design uses one pure projection that emits stable render units and all indexes consumed by locators and attachments:
interface AgentTranscriptRenderUnitProjection {
units: readonly AgentTranscriptRenderUnit[];
unitIndexByRowIndex: ReadonlyMap<number, number>;
unitIndexByRenderKey: ReadonlyMap<string, number>;
anchorUnitIndexByPresentationGroup: ReadonlyMap<string, number>;
lastUnitIndexByCanonicalTurnId: ReadonlyMap<string, number>;
unitIndexByAttachmentId: ReadonlyMap<string, number>;
}
The active Turn is split from the start. A settled collapsed Turn remains one unit. A stable group-anchor key survives active-to-settled presentation so the visible anchor is not deleted during collapse.
This phase requires its own design review and performance evidence. It is not part of the TanStack upgrade.