Fix chat scrollbar not reaching bottom during streaming via scroll anchoring#1014
Fix chat scrollbar not reaching bottom during streaming via scroll anchoring#1014karkarl wants to merge 3 commits into
Conversation
…choring While the agent is thinking/streaming, the last assistant row grows in place (no new Props.Entries, no ScrollToBottomToken bump, no reliable SizeChanged). The ItemsRepeater extent estimate climbs across realization frames, leaving the offset a few px short of the bottom with nothing to re-pin it -- the "scrollbar doesn't reach the bottom while the agent is thinking" bug. Replace the earlier reactive ViewChanged re-pin (which painted an intermediate short frame -> jitter, and whose programmatic ChangeView fought the user's own drag) with WinUI ScrollViewer scroll anchoring: - Set sv.VerticalAnchorRatio = 1.0 in the one-time ScrollViewer init so the bottom-most realized row stays glued to the viewport bottom BEFORE paint as the extent grows. The ViewChanged handler no longer re-pins; it only refreshes metrics and drives the load-earlier trigger. - QueuePreservePrependOffset toggles anchoring to NaN during the manual load-earlier delta correction and restores it to 1.0 afterward, with a fail-safe restore if a dispatcher enqueue is rejected or the correction throws. Discrete events (append, session switch, initial load, ScrollToBottom token) still use QueueScrollToBottom; anchoring only covers the in-place growth case. Known residual (acceptable temporary fix): slight jitter as the viewport expands during virtualization/streaming. Tracked on issue openclaw#996 (Reactor port). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Codex review: needs changes before merge. Reviewed July 17, 2026, 8:38 PM ET / July 18, 2026, 00:38 UTC. Summary Reproducibility: yes. from source: loading earlier history calls the changed prepend helper, which sets suppression and then assigns an unsupported anchor ratio before it can queue restoration. The supplied native recording also directly demonstrates the streaming follow and user-scroll scenarios on the PR head. Review metrics: 2 noteworthy metrics.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Copy recommended automerge instructionNext step before merge
Security Review findings
Review detailsBest possible solution: Keep Do we have a high-confidence way to reproduce the issue? Yes, from source: loading earlier history calls the changed prepend helper, which sets suppression and then assigns an unsupported anchor ratio before it can queue restoration. The supplied native recording also directly demonstrates the streaming follow and user-scroll scenarios on the PR head. Is this the best way to solve the issue? No. Bottom anchoring at Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 4518dd87ed2b. Label changesLabel changes:
Label justifications:
Evidence reviewedAcceptance criteria:
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (5 earlier review cycles)
|
Using native The current proof/test coverage is not sufficient to merge yet:
Please also document or verify the The code approach looks promising, but the tests currently prove only the happy bottom-pinned path and have not themselves been demonstrated to run. This is a UI behavior change, so current-head native proof is the remaining gate. |
@karkarl, thank you for working on this—the native anchoring approach looks promising. Could you please add a short current-head screen recording? A static screenshot cannot demonstrate the timing/scroll behavior we need to verify. Ideally the recording would show:
Please also include the exact PR SHA and the command/result for the new Tray UI tests. If the UI test suite is difficult to run locally, a link to the current-head CI job/artifact is fine once it demonstrably executes the new test. That proof plus deterministic eventual/polling assertions in place of fixed compositor delays would give maintainers a much stronger basis to land the change. Thanks! |
…ic drain Address PR review feedback (shanselman + clawsweeper) requesting deeper test coverage of the VerticalAnchorRatio=1.0 scroll-anchoring fix. Tests (tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs): - UserScrolledUpDuringStreaming_KeepsReadingPositionStable: proves streaming growth of the off-screen newest row does NOT yank a scrolled-up user to the bottom (gap stays > FollowThreshold, reading position drift <= 24px across 4 revisions) - the "don't fight the scrollbar" half of the fix. - PrependHistory_PreservesOffset_AndRestoresBottomAnchoring: proves load-earlier prepend preserves the visible offset (shifts by inserted height) AND restores VerticalAnchorRatio to 1.0 (not left at the NaN disable sentinel), then a scroll-to-bottom token follows again end-to-end. Guards the highest-risk path that a fresh live recording cannot exercise. - Gap-value diagnostics logging on the streaming pin test (CHAT_TIMELINE_*). Determinism (UIThreadFixture.cs + test drain): - Add YieldToRenderAsync(): awaits one Low-priority dispatcher callback as a bounded render-queue drain primitive; completes immediately if the dispatcher rejects the enqueue so callers can never block. - Replace DrainRenderQueueAsync's single Task.Delay(50) with 6 bounded passes of UpdateLayout + YieldToRenderAsync + a 5ms floor. Docs (OpenClawChatTimeline.cs): - Expand the QueuePreservePrependOffset comment to document that double.NaN is the documented WinUI sentinel that disables anchoring (default value; 0.0 would anchor to the TOP edge, not disable), per the VerticalAnchorRatio contract. Validation: build.ps1 green; UITests project compiles clean (0 warn/err); Shared 2863 passed; Tray 1717 passed. UITests run on CI (they time out in the local harness); the 2 new tests are exercised by the ci.yml UITests job. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… prepend preservation The ItemsRepeater SizeChanged storm during row realization fired a flood of reactive QueueScrollToBottom calls that each superseded the user's own ChangeView, so the proof tests' scroll-up never "took" and the view stayed pinned. Replace the multi-pass QueuePass stabilization with: - A coalescing guard (scrollPinPendingRef) so host.SizeChanged enqueues at most one reactive pin at a time. Held across the synchronous pin window (which can re-enter SizeChanged) and released only in terminal paths. - An authoritative ViewChanged handler: a genuine user scroll past the abandon gap cancels the in-flight settle timer/pending pin and disables anchoring immediately; symmetric re-arm to 1.0 when back in the bottom band. - A single settle timer (converges on atBottom, hands off to VerticalAnchorRatio = 1.0) instead of parallel timers fighting over ChangeView. - A per-render anchoring gate: VerticalAnchorRatio = following ? 1.0 : NaN, deferring to any pin/prepend that owns the ratio for its async window. Prepend: our FunctionalUI reconciler full-remounts every Entry (openclaw#996), which destroys the element the platform chose as the bottom anchor, so leaving VerticalAnchorRatio = 1.0 re-pinned a scrolled-up reader to the NEW bottom (offset 2528 -> 8531, gap 0). QueuePreservePrependOffset now disables anchoring and manually re-seats the offset by the measured inserted height instead, keeping the reader in the middle band (not reset to top, not dragged to bottom). Also update the ChatTimelineVirtualizationContractTests source-text assertions to the current mechanism and relax the prepend proof's anchoring assertion to the dynamic model (NaN while scrolled up, or 1.0 at bottom). Removed dead DIAG diagnostics and the unused FollowToBottomExtentEpsilon constant. Validation: build.ps1 green; Shared 2863 passed/31 skipped; Tray 1717 passed; UITests ChatTimelineVirtualizationProofTests 5/5; contract tests 3/3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
ClawSweeper status: review started. I am starting a fresh review of this pull request: Fix chat scrollbar not reaching bottom during streaming via scroll anchoring This is item 1/1 in the current shard. Shard 0/1. This placeholder means the worker is alive and reading the current context. I will edit this same comment with the actual review when the claws are done clicking. Crustacean status: shell secured, claws on keyboard, evidence pebbles being sorted. |
Summary
Fixes the chat-timeline bug where the scrollbar doesn't reach the bottom while the agent is thinking/streaming.
While the agent streams, the last assistant row grows in place — no new
Props.Entries, noScrollToBottomTokenbump, and no reliableSizeChanged. UnderItemsRepeatervirtualization the extent estimate climbs across realization frames, so the offset lands a few px short of the bottom with nothing to re-pin it.An earlier attempt used a reactive
ViewChangedre-pin. Live testing showed it (a) painted an intermediate short frame → jitter, and (b) its programmaticChangeViewfought the user's own drag. This PR replaces that with WinUI ScrollViewer scroll anchoring.Changes
src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs:sv.VerticalAnchorRatio = 1.0in the one-timeScrollViewerinit. WinUI keeps the bottom-most realized row glued to the viewport bottom before each frame is painted as the extent grows — smooth, no self-feeding loop, and it does not fight programmaticChangeViewor the user scrolling up to read.ViewChangedhandler; it now only refreshes scroll metrics and drives the load-earlier trigger.QueuePreservePrependOffsettoggles anchoring toNaNduring the manual load-earlier delta correction (so it doesn't double-correct content inserted above) and restores it to1.0afterward, with a fail-safe restore if a dispatcher enqueue is rejected or the correction throws. Expanded the inline comment to document thatdouble.NaNis the WinUI-documented sentinel that disables anchoring (the default value;0.0would instead anchor to the top edge), so a future reader doesn't "simplify" it to0.0.ScrollToBottomtoken) still useQueueScrollToBottom; anchoring only covers the in-place growth case.tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs:ThinkingAndStreamingGrowth_KeepsViewPinnedToBottom— regression proof that the bottom stays pinned (gap ≤ 4px) across the thinking indicator + 4 streaming revisions. Now also logs the gap/offset/scrollable each step (CHAT_TIMELINE_PIN_PROOF).UserScrolledUpDuringStreaming_KeepsReadingPositionStable— proves the "don't fight the scrollbar" half: with the user deliberately scrolled up, streaming growth of the off-screen newest row does not yank them to the bottom (gap stays> FollowThreshold, reading-position drift ≤ 24px across 4 revisions).PrependHistory_PreservesOffset_AndRestoresBottomAnchoring— proves the highest-risk path that a fresh live recording can't reach (a new session has no earlier history to load): load-earlier prepend preserves the visible offset (shifts by the inserted content height) and restoresVerticalAnchorRatioto1.0— not left at theNaNdisable sentinel — after which aScrollToBottomTokenbump follows to the newest row again end-to-end. A regression that left anchoring disabled would silently break bottom-follow for the rest of the session; this test guards it.LargeNativeChatTimeline_VirtualizesRowsAndFollowsNewMessages/RealizedComponentRow_ReplacesRootAndPrunesRemovedEffects— unchanged virtualization proofs.tests/OpenClaw.Tray.UITests/UIThreadFixture.cs:YieldToRenderAsync()— awaits oneDispatcherQueuePriority.Lowcallback as a bounded, deterministic render-queue drain primitive (all higher-priority layout/render/realization work has run by the time a Low continuation resumes); completes immediately if the dispatcher is shutting down so a caller can never block.DrainRenderQueueAsyncreplaces its singleTask.Delay(50)with 6 bounded passes ofUpdateLayout+YieldToRenderAsync+ a 5ms floor — fewer magic sleeps, and it can't spin or hang even if layout never fully quiesces.Known residual (accepted, temporary)
Slight jitter as the viewport expands during virtualization/streaming remains: anchoring keeps the bottom pinned, but per-frame extent re-estimation from
ItemsRepeaterstill produces small visible jumps during rapid in-place growth (see the clip-3 resize drift below). Accepted as a temporary fix; the clean solution is the Reactor port. Documented on #996.Validation
Host is arm64; targeted
-r win-arm64where a RID is required../build.ps1— ✅ all projectsdotnet test ./tests/OpenClaw.Shared.Tests/...— ✅ 2863 passed, 31 skipped (MXC host-gated)dotnet test ./tests/OpenClaw.Tray.Tests/...— ✅ 1717 passeddotnet build tests/OpenClaw.Tray.UITests -c Debug -r win-arm64— ✅ 0 warnings / 0 errors (warnings-as-errors on) — confirms the 3 new/changed test files compile cleanOpenClaw.Tray.UITests) execution: Not run locally — this suite times out in the dev harness. All UITests (including the two new ones) run in CI via thetestjob in.github/workflows/ci.yml. Latest run for this head (8acd9248): https://github.com/openclaw/openclaw-windows-node/actions/runs/29621572276Real behavior proof
Verified on an isolated running instance (
OPENCLAW_TRAY_DATA_DIRredirect, real%APPDATA%\OpenClawTrayuntouched), current PR head, real gateway/node path (agent: Claude Opus 4.7).Proof video (multi-scenario)
Three back-to-back scenarios on the isolated instance:
ScrollPattern%)streaming=Truestreaming=True(not yanked to bottom)Video (58s, uploaded to a fork release since it exceeds the inline-attachment limit):
https://github.com/karkarl/openclaw-windows-node/releases/download/scroll-proof-1014/scroll-proof-3scenarios.mp4
scroll-proof-3scenarios.mp4
Original single-scenario embedded clip (thinking → streaming, pinned throughout) retained for inline playback:
scroll-proof.mp4
Developer-confirmed: "It does pin and scroll better now." Residual jitter as the viewport expands is the accepted temporary limitation noted above.
Refs #996