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:
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.
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.
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.
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.
- 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.
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 Gamewhile 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:
useChatScrollPinunconditionally clears pin/read intent and schedules a scroll to the end whenevergroupCounts.lengthgrows. It does not check whether the reader had manually left the tail. This directly explains a new message pulling the page away from history.useChatScrollperforms one immediate scroll plus four consecutive animation-frame corrections. The same content change can also trigger itsResizeObserver, the tail-follow effect, and footer-spacer remeasurement, so several programmatic writers may adjust the same scroll root during one rendered update.PlanningIndicatorBridgeinserts and removesAgent 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.ChatHistoryListgives each virtualized group a key containing mutabledisplayStatus,activityStatus, anddisplayText.length. Streaming tokens change that key, which can remount the measured row exactly while its height is changing and destroy a stable reading anchor.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
following_tailanddetached_reading.detached_readingimmediately. 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.following_tail.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
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
scrollTopwhile detached. Height changes above the anchor must compensate so the same historical content stays under the reader's eyes.3. Stabilize streaming and typing layout
4. Keep lifecycle and consumers isolated
Scope boundaries
Failure scenarios that must be covered
scrollTop.Agent is typing...appears, changes mode, disappears, or is replaced by the first assistant token and causes an up/down jump.Acceptance
Deterministic frontend tests
Rendered E2E and packaged Tauri
scrollTop, invoke Tauri commands, or click elements to substitute for the user path under test.pnpm run tauri:build:fastand operate the packaged Tauri App through Computer Use.Agent is typing.... The paragraph must remain in place and the scroll-to-bottom button must remain available.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
Effort
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.