Skip to content

fix(chat): preserve reader position during streaming updates #1002

Description

@ShiboSheng

Goal and ownership

Fix the shared Chat transcript so incoming messages, streaming deltas, and the Agent is typing... row never steal the viewport from a user who is reading older history. This is a standalone ChatPanel correctness/performance issue, not an Agent Org scheduler or control-plane change.

The defect was observed in the real-provider Team session Build Local Texas Holdem Game while validating #763 / PR #991. It is intentionally separate from Coordinator safety (#995) and formal convergence (#997), because the same ChatPanel owner is used by ordinary SDE and Agent Org Member sessions.

Evidence and root cause

The current code has split and conflicting scroll ownership:

  1. useChatScrollPin unconditionally clears pin/read intent and schedules a scroll to the end whenever groupCounts.length grows. It does not check whether the reader had manually left the tail. This directly explains a new message pulling the page away from history.
  2. useChatScroll performs one immediate scroll plus four consecutive animation-frame corrections. The same content change can also trigger its ResizeObserver, the tail-follow effect, and footer-spacer remeasurement, so several programmatic writers may adjust the same scroll root during one rendered update.
  3. PlanningIndicatorBridge inserts and removes Agent is typing... as a synthetic item in the latest group. That changes the group count, tail-follow key, measured height, and footer spacer. Its appearance/disappearance therefore enters several scroll paths instead of being a layout-only update.
  4. ChatHistoryList gives each virtualized group a key containing mutable displayStatus, activityStatus, and displayText.length. Streaming tokens change that key, which can remount the measured row exactly while its height is changing and destroy a stable reading anchor.
  5. Existing focused tests cover pure active-group pin calculations, but do not mount the real transcript and prove the detached-reader contract across new groups, streaming text, typing-row transitions, static-to-virtualized boundaries, or repeated session lifecycle.

This is an architectural UI defect: the authoritative “follow latest versus preserve my reading position” decision is duplicated across effects and is inferred from short timing windows rather than owned by one viewport policy.

State after merge

  • One Chat viewport owner decides between two durable mounted states: following_tail and detached_reading.
  • A real user scroll away from the latest content enters detached_reading immediately. Incoming user/assistant messages, tool activity, streaming deltas, typing/planning rows, row remeasurement, collapse/finality changes, and footer/composer height changes preserve the visible anchor.
  • Initial session open follows the existing latest-content policy. Explicitly submitting a message or clicking the existing “scroll to bottom” button re-enters following_tail.
  • While following, streamed content stays at the latest content with one coalesced correction owner. While detached, the existing scroll-to-bottom affordance remains visible and is the only way incoming content can reattach automatically apart from the user's own explicit Send action.
  • Virtualized row/group identity is stable for the lifetime of the underlying turn; mutable text length and activity/display status are not keys. Height changes are handled by measurement without remounting the reading anchor.
  • Agent is typing... may appear and disappear without moving a detached reader and without visible up/down oscillation at the tail.

Implementation requirements

1. Define one viewport-follow contract

  • Introduce an explicit source-neutral viewport policy owned beside the Chat scroll root; do not derive intent independently in several effects.
  • Classify scroll causes: real user scroll, explicit jump-to-latest, explicit user Send, initial session open, search/minimap navigation, collapse/expand, incoming append, streaming mutation, synthetic footer transition, resize, and programmatic anchor correction.
  • Programmatic corrections must not be mistaken for user intent, and fixed millisecond suppression windows must not be the correctness boundary.
  • Remove or reduce parallel scroll writers in useChatScroll, useChatScrollPin, footer-spacer measurement, and virtualized-list updates so every automatic adjustment routes through the same policy.

2. Preserve a detached reader's anchor

  • Capture a stable rendered turn/group identity and pixel offset before an above/below-viewport structural or height change, then restore that anchor after measurement when required.
  • New tail content must not change scrollTop while detached. Height changes above the anchor must compensate so the same historical content stays under the reader's eyes.
  • Search, minimap, turn-page, and collapse/expand navigation may move the viewport for their explicit action but must not silently re-enable tail following.
  • Static history and the virtualized history path must expose the same behavior, including the transition around the virtualization threshold.

3. Stabilize streaming and typing layout

  • Use stable turn/group keys derived from immutable event/turn identity. Do not include streamed text length or mutable display/activity status in React/virtualizer keys.
  • Keep row measurement responsive to real height changes through the existing observer/virtualizer APIs without remounting rows per token.
  • Treat planning/typing/compaction rows as tail layout changes. Their mount, mode change, and removal must follow the same viewport policy rather than independently requesting a scroll.
  • Coalesce bursty streamed height changes to at most one pending animation-frame correction for one scroll root. Preserve terminal/final updates and cancel the pending frame on session switch/unmount.

4. Keep lifecycle and consumers isolated

  • A -> B -> A session switching must not leak manual/programmatic timestamps, pending animation frames, observers, or follow intent between sessions. Persistent cross-session scroll restoration is not added by this issue unless separately approved; retain a deterministic existing open-session policy.
  • Hidden/inactive sessions perform no scroll animation or measurement loop. On visibility return, perform at most one bounded remeasurement/reconciliation.
  • Ordinary SDE, imported/external history, Agent Org canonical Member pages, and Group/overview chat surfaces that reuse the owner must share the same contract. Consumer-specific bypasses are not allowed.
  • Keep the current scroll-to-bottom control and localized label unless testing proves a new visible state is necessary. Do not add a second button or Agent Org-only workaround.

Scope boundaries

  • No Coordinator, Task, TaskExecution, Inbox, UserDirectedWork, Return-to-Work, finalization, or Provider behavior change.
  • No message ordering, EventStore persistence, transport, or database schema change unless root-cause investigation disproves the current evidence and scope is re-approved.
  • No global disabling of auto-follow: users at the tail must continue seeing live output.
  • No chat visual redesign, unrelated composer changes, new virtualization dependency, polling, interval, or per-session background timer.
  • No persistent scroll-position database/browser-storage format in this issue.
  • Do not use DOM query/mutation workarounds as production ownership; the mounted scroll root and virtualizer remain the authority.

Failure scenarios that must be covered

  • Reader scrolls several turns upward; a new user/assistant group arrives and forces the transcript to the bottom.
  • Streaming text changes the virtual row key/remounts the turn or repeatedly changes the reader's scrollTop.
  • Agent is typing... appears, changes mode, disappears, or is replaced by the first assistant token and causes an up/down jump.
  • Footer spacer/composer height or a row above the viewport changes and shifts the visible historical paragraph.
  • A delayed animation frame, observer callback, or async measurement from Session A writes into Session B.
  • Clicking jump-to-latest does not resume following, or a user who is already at the bottom stops following live output.
  • Search/minimap/collapse navigation accidentally reattaches tail follow.
  • Static and virtualized list paths disagree, including when item count crosses the virtualization threshold.
  • Repeated open/close or hidden/visible cycles accumulate observers, listeners, animation frames, timers, or render work.

Acceptance

Deterministic frontend tests

  • Mount the real Chat transcript/viewport owner with controllable scroll metrics and assert the full cause/state matrix, not only a pure selector.
  • Detached reader: append new group, append tool event, grow streaming text, toggle planning/typing/compaction row, finalize/collapse a turn, resize footer/composer, and remeasure rows. Assert the stable anchor identity/offset and no tail scroll request.
  • Following reader: the same burst remains at latest content with at most one pending animation-frame correction per scroll root and no competing smooth/auto scroll sequence.
  • Explicit Send and scroll-to-bottom reattach; manual upward scroll detaches immediately; programmatic anchor correction does not change intent.
  • Stable virtual keys remain unchanged across streamed text/status/activity updates and change only when the immutable turn/group identity changes.
  • Cover short static history, long virtualized history, threshold crossing, large history, search/minimap/collapse, A -> B -> A, unmount, hidden/visible, and StrictMode-style repeated effect setup/cleanup.
  • Prove one listener/observer owner per mounted scroll root, symmetric cleanup, no stale callback write, and no unbounded per-session retained map.

Rendered E2E and packaged Tauri

  • Add a focused rendered WebDriver spec that uses real wheel/trackpad-equivalent or keyboard scrolling and rendered controls. JavaScript may read diagnostics for assertions but must not set scrollTop, invoke Tauri commands, or click elements to substitute for the user path under test.
  • Build the exact HEAD with pnpm run tauri:build:fast and operate the packaged Tauri App through Computer Use.
  • In both an ordinary SDE session and an Agent Org Member session, create enough real history to scroll upward, leave a recognizable paragraph centered, then let a real provider stream text and show/hide Agent is typing.... The paragraph must remain in place and the scroll-to-bottom button must remain available.
  • Click the real scroll-to-bottom button and confirm subsequent streamed output follows without oscillation. Send a new user message and confirm that explicit action reattaches according to the contract.
  • Repeat with long virtualized history, narrow/wide windows, light/dark themes, Session A -> B -> A, window hidden/restored, and app quit/relaunch where applicable.
  • Capture before/after video or screenshots plus WebView performance evidence showing bounded scroll calls, stable row mounts/keys, and no repeated post-unmount work.

Debug fixtures may create deterministic long history or control streaming timing and may read scroll/observer evidence. They may not replace real scrolling, Send, jump-to-latest, Session switching, or the real-provider streamed scenario.

Performance and compatibility gates

  • One mounted active transcript owns at most one pending animation frame for follow/anchor correction and one bounded observer/listener set.
  • A streaming burst does not remount a group per token and does not rerender unrelated Session views.
  • Hidden/inactive five-minute windows have zero scroll animation and no periodic measurement work; visibility return performs at most one bounded reconciliation.
  • Ten repeated mount/unmount and A -> B -> A cycles leave listener/observer/frame/timer counts at baseline and show no staircase memory growth.
  • Ordinary SDE Send/Queue/Force Send/Stop/Resume/Compaction, imported history pagination, Agent Org direct work, and existing search/minimap/collapse behavior remain unchanged apart from scroll stability.

Effort

Category P50 P90
Production 1,400 review lines 2,800 review lines
Tests / E2E / measurement 1,800 3,700
Cleanup / audit / evidence 400 900
Total 3,600 7,400
Substantive files 18-26 32-48
Audit/evidence documents 1-2 2-3

At 70% of P50, compare the real diff with this contract. Stop for renewed scope approval if investigation requires message persistence/transport changes, a new virtualization dependency, persistent cross-session storage, more than three unplanned production files / 200 production lines, or total growth above 20%. Do not remove detached-reader, real-provider, packaged-app, long-history, lifecycle, or performance evidence to fit the estimate.

Plain-language contract

If I scroll up to read an old message, the chat must leave the page alone. New messages and “Agent is typing” can keep arriving, but they stay below me and the existing down-arrow tells me there is newer content. When I click that arrow or send my own message, I am choosing to follow the live conversation again; it should then stay smoothly at the latest message without bouncing up and down.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Effort: HighUXImprovements to user experience, workflow smoothnessbugSomething isn't workingchatChat, composer, markdown, canvas, browser, or terminal surfacesfrontend-uiFrontend UI, design system, accessibility, layout, or themingperformancePerformance, responsiveness, or resource-usage work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions