fix(webui): glide the streaming chat tail instead of snapping it per tick - #7378
StephenZ06 wants to merge 1 commit into
Conversation
…tick While an assistant reply typed itself out, the transcript did not follow the text smoothly. It sat still for several render ticks and then jumped a whole line, and the jump repeated for the length of the answer. The streaming follow path snapped `scrollTop = scrollHeight` on every render tick. A tick only adds a couple of words, so the scroll position does not change at all until a line WRAPS -- at which point the whole line height is applied in one painted step. The same path also rebuilt the settle ResizeObserver (disconnect + `new ResizeObserver` + observe + two timers) per tick, i.e. ~30 observer teardown/rebuild cycles a second on a transcript that may be thousands of nodes deep, which is the dominant per-frame cost on mobile. While a turn is streaming -- or the word fade is still playing text out after the SSE `done` -- `scrollIfPinned()` now hands the tail to a single rAF loop, `_tailFollowFrame()`, that eases `scrollTop` toward the bottom: one `scrollHeight` read and at most one `scrollTop` write per frame, closing 26% of the remaining gap so a newly wrapped line arrives as a short glide. It is bounded on both ends: a 36px lag ceiling so the newest lines never park below the fold when the word fade is off and a tick can add several lines at once, and a 600px snap threshold so a hopeless gap does not crawl. It lands exactly on `scrollHeight - clientHeight` for the last pixel, and parks itself after ten quiet frames rather than holding a permanent rAF loop. Ownership is single-writer: `_settleMessageScrollToBottom()` and `_cancelBottomSettle()` stop the glide, so the completion and late-layout paths (Prism, KaTeX, Mermaid, images) still re-anchor the bottom exactly as before. The glide refuses on the same signals the settle path's ResizeObserver callback refuses on, so the sticky-unpin model (nesquena#3343/nesquena#4295) is unchanged, plus one mobile addition: it yields while a finger is actually DOWN on the transcript, so a per-frame write can never cancel an in-progress drag or iOS momentum. Deliberately not the 1200ms `_recentMessageTouchScrollIntent()` tail -- touchstart marks that on any tap (a copy button, expanding a tool card), and pausing follow for 1.2s after a tap would regress against the current settle path, which does not check it at all. Three cadence hitches in the word fade are fixed alongside, all the same shape: a `setTimeout(33) + rAF` pair fires anywhere between 33ms and 50ms depending on where the timer lands relative to the next frame, so the released word wave arrived unevenly even though the pacing math is time-based. `_scheduleRender()` now polls rAF against the interval (a steady two-frame beat at 60Hz); the self-reschedule while the fade is catching up no longer stacks a 33ms timer on top of `_scheduleRender()`'s own 33ms gate, which had pushed the next playout tick out to as much as 66ms whenever tokens stopped arriving; and the post-done drain gets the same vsync-aligned beat, which is where the hitch was most visible -- on an answer's last words. The drain marks a self-expiring deadline rather than a boolean so an abandoned drain (session switch, cancel, teardown) can never strand ownership away from the settle. Two integrations with the surrounding scroll code on this base: the glide yields to an active `_messageJumpScrollOwner` for the same reason `scrollIfPinned()` returns early on one (nesquena#6621) -- a jump deliberately holds the reader at its target across smooth-scroll frames, and two writers would fight each frame -- and the new call in `scrollIfPinned()` carries a `typeof` guard so the unit harnesses that extract that function body without the tail-follow helpers stay inert, matching the guard style already used there. Tests: tests/test_streaming_tail_follow_glide.py drives the real extracted helpers in Node against a fake scroller and a manually pumped frame queue. All 12 fail on the pre-change files and pass on these. The neighbouring scroll/stream/fade/jitter/anchor suite (978 tests) passes, as does the full suite apart from tests/test_tls_aware_probe.py, which fails on this base before the change and covers scripts/lib/health_probe.sh, not this code. Verified on the live instance: rebuilt and recreated the container, health check green, and the served /static/ui.js and /static/messages.js carry the new code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9kbjA9C6YY7f173xr6N6G
SummaryI pulled Code referenceAt const _markDraining=()=>{
window._streamFadeDrainingUntil=performance.now()+400;
};
const _endDraining=()=>{
window._streamFadeDrainingUntil=0;
};
A post-done drain can continue pumping for the old Diagnosis / recommendationScope the drain lease to its owner and make release compare-and-clear. Capture an immutable token plus This is better than a deadline alone: expiry prevents a permanent stuck flag, but it does not prevent a stale producer from renewing or clearing a newer producer state. The glide path at Test planThe current deadline test at |
Manny7717
left a comment
There was a problem hiding this comment.
Verified locally on head 3829d45 (node + repo test harness):
Regression proven. All 12 tests in test_streaming_tail_follow_glide.py FAIL on base e168b67 (helpers don't exist there) and PASS on head — including the ownership-handoff, drain-deadline, and vsync-cadence tests, which exercise the real extracted functions against a fake scroller. Neighboring scroll suites (issue1360, 1731, 3319, 3470, 3479, 2111, 3250): 38/38 pass on head. Streaming/fade suites (2713, 3397, 2965, inflight reuse, cancel owner guard): 91 passed total with the new file. ruff_lint.py --diff origin/master = 0 new violations; node --check clean on both JS files.
Logic review (head):
- Glide loop: one rAF chain, ease = max(0.6px, 26% of gap, gap−36px lag ceiling), snap at gap>600px or gap−step<1, parks after 10 quiet frames, refuses on the exact settle-path refusal set + finger-down + jump-to-question owner. Single writer at a time:
_cancelBottomSettle()and_settleMessageScrollToBottom()both call_tailFollowStop(), so settle and glide can never interleave frames. - Drain handoff:
_streamFadeDrainingUntilis a self-expiring deadline (400ms, re-marked on every active step), not a boolean — an abandoned drain (session switch/cancel) expires it automatically, soscrollIfPinned()falls back to the full settle (pre-fix behavior) at worst. All exits clear it explicitly (null assistantBody, done, forced-done). - The
setTimeout(33)+rAF→ rAF-poll pacing in_scheduleRender/drain is behavior-preserving for the fade math (time-based, 33/66ms interval with a 4ms tolerance) and strictly smooths the release cadence; thetypeof requestAnimationFramefallback keeps non-browser contexts working.
Two non-blocking nits: (1) pump() in messages.js calls bare requestAnimationFrame without the typeof guard its sibling branch has — browser-only path so harmless, but the guard would make it consistent; (2) the glide reads scrollHeight/clientHeight every frame — intentional and documented (one read, one write), fine for a transcript this size.
Clean, well-tested fix for a real UX stutter. Approve.
The problem
While an assistant reply typed itself out, the transcript did not follow the text smoothly. It sat still for several render ticks and then jumped a whole line, and the jump repeated for the length of the answer. Most visible on mobile, but present on desktop too.
Root cause
The streaming follow path snapped
scrollTop = scrollHeighton every render tick. A tick only adds a couple of words, so the scroll position does not change at all until a line wraps — at which point the whole line height is applied in one painted step. That is the stutter: it is not dropped frames, it is a step function.The same path also rebuilt the settle
ResizeObserver(disconnect +new ResizeObserver+ observe + two timers) per tick, i.e. ~30 observer teardown/rebuild cycles a second on a transcript that may be thousands of nodes deep. That is the dominant per-frame cost on mobile.The fix
While a turn is streaming — or the word fade is still playing text out after the SSE
done—scrollIfPinned()hands the tail to a single rAF loop,_tailFollowFrame(), that easesscrollToptoward the bottom: onescrollHeightread and at most onescrollTopwrite per frame, closing 26% of the remaining gap so a newly wrapped line arrives as a short glide.Bounded on both ends:
scrollHeight - clientHeightfor the last pixel, so the near-bottom predicates elsewhere in the file are not left measuring against a fractional residue.Ownership is single-writer:
_settleMessageScrollToBottom()and_cancelBottomSettle()stop the glide, so the completion and late-layout paths (Prism, KaTeX, Mermaid, images) still re-anchor the bottom exactly as before.Invariants preserved
The glide refuses on the same signals the settle path's ResizeObserver callback refuses on, so the sticky-unpin model (#3343 / #4295) is unchanged, plus two additions for this code's neighbours:
_recentMessageTouchScrollIntent()tail —touchstartmarks that on any tap (a copy button, expanding a tool card), and pausing follow for 1.2s after a tap would regress against the current settle path, which does not check it at all._messageJumpScrollOwnerfor the same reasonscrollIfPinned()returns early on one (fix(chat): prevent first response jump from snapping back #6621) — a jump deliberately holds the reader at its target across smooth-scroll frames, and two writers would fight each frame.Siblings found and fixed
Three cadence hitches in the word fade, all the same shape: a
setTimeout(33) + rAFpair fires anywhere between 33ms and 50ms depending on where the timer lands relative to the next frame, so the released word wave arrived unevenly even though the pacing math is time-based._scheduleRender()now polls rAF against the interval — a steady two-frame beat at 60Hz._scheduleRender()'s own 33ms gate, which had pushed the next playout tick out to as much as 66ms whenever tokens stopped arriving.donedrain gets the same vsync-aligned beat — this is where the hitch was most visible, on an answer's last words.The drain marks a self-expiring deadline rather than a boolean, so an abandoned drain (session switch, cancel, teardown) can never strand ownership away from the settle.
Tests
tests/test_streaming_tail_follow_glide.py— 12 tests. The behavioural ones drive the real extracted helpers in Node against a fake scroller and a manually pumped frame queue, so they exercise the shipped code, not a replica:S.activeStreamIdclears, and expires on its ownProof they fail before the fix: stashing the two source files and re-running gives
12 failed. Restoring gives12 passed.Verification run on this branch:
1029 passed15082 passed, 119 skipped, 2 failed— the two failures aretests/test_tls_aware_probe.py, which fails on this base before the change and coversscripts/lib/health_probe.sh, not this code.Verified running
Rebuilt the container image and recreated it, health check green, and confirmed the served
/static/ui.jsand/static/messages.jscarry the new code. Streaming a long prose reply follows the text continuously instead of stepping a line at a time; scrolling up mid-stream still stops follow dead and keeps it stopped.What I could not verify
overflow-anchorinteraction that_fixMobileScrollJank()already suppresses during streaming renders) is from reading the surrounding code, not from a device. Worth a check on a real handset before merge.🤖 Generated with Claude Code
https://claude.ai/code/session_01S9kbjA9C6YY7f173xr6N6G