fix(scroll): pin viewport to bottom on resume so deferred layout can't strand user mid-conversation#342
Conversation
…t strand user mid-conversation
On resuming an existing session, the chat consistently lands mid-conversation
instead of at the end. Frequently the landing position is the todo widget. The
bottom button briefly shows "Latest" then flips to "New activity"; clicking it
only scrolls part of the way down, requiring repeated clicks. Reproduces on
desktop Chromium and Firefox, worst on long sessions with tool output, code
blocks, todo widgets, or image attachments.
## Root cause
history_done in app-messages.js calls scrollToBottom() once via a single
requestAnimationFrame, capturing scrollHeight before deferred content
finishes laying out: tool widgets render via tools.js post-replay, markdown
and syntax highlighting reflow code blocks, image loads change message
heights asynchronously, the todo widget's IntersectionObserver-driven sticky
behavior reflows itself after first paint. scrollHeight keeps growing after
the rAF pin. The scroll listener in app.js then sees distFromBottom > 150,
sets isUserScrolledUp = true, and flips "Latest" to "New activity".
forceScrollToBottom() has the same one-frame problem: every click reaches
the bottom-at-this-instant, more content lays out below, button reappears,
repeat.
A second compounding issue: every per-tool render in tools.js (TodoWrite,
file edits, command results, plan cards, permission prompts,
askUserQuestion, subagent activity, thinking, turn-meta) calls
ctx.scrollToBottom() unconditionally, including during history replay.
Per-tool scrolls during replay re-anchor the viewport to whichever tool
widget grew last (commonly the todo widget when it has unfinished items).
## Fix
Sticky-bottom mode in app-rendering.js:
- A ResizeObserver on #messages and direct children re-pins
scrollTop = scrollHeight on every height change while armed.
- Quiet-window debounce: each ResizeObserver callback resets a timer;
sticky-bottom only disarms after no resize for ~750ms. Long-settling
sessions naturally extend the window. Hard 8s ceiling so we never
lock the scroller indefinitely.
- Real user input (wheel, touchmove, keydown PageUp/Home/ArrowUp)
disarms immediately, so we never fight the user.
Wired in:
- app.js scroll listener consults getStickyBottom() and ignores
growth-induced scroll events while armed, so isUserScrolledUp doesn't
trip falsely when the ResizeObserver itself is moving the scroll.
- app-messages.js history_done arms sticky-bottom unless a navigate
target (file-edit deeplink) is pending, in which case the existing
scrollIntoView path handles it.
- forceScrollToBottom() now arms sticky-bottom instead of doing a
one-frame pin, so "New activity" reaches the true bottom in one click.
Per-tool scroll calls in tools.js route through a maybeScrollToBottom()
helper that no-ops while store.get('replayingHistory') is true. Live
behavior is unchanged: TodoWrite updates, tool runs, and assistant
streams still auto-scroll exactly as before.
No new dependencies. No new state on disk. No server changes.
|
Hi @akuehner , scroll fix itself looks great. But import { handleAgentsList, handleAgentFavoriteToggled } from './agent-picker.js';
I just added a CI check ( Could you drop the agent-picker import and the two related |
Pulled in accidentally from another branch; agent-picker.js does not exist on chadbyte/main, so the import breaks the client at load time and trips the new pr-checks unresolved-import lint. Removes the import and the two related message cases (agents_list, agent_favorite_toggled). Addresses chadbyte#342 review.
|
See #345 for the follow-up issue documenting remaining scroll latency on long sessions and the proposed improvements (batched highlight, dead-session todo compaction, |
|
This issue has been resolved in version 2.36.1-beta.1 (main). To update, run: -- Clay Deploy Bot Build anything, with anyone, in one place. |
Problem
On resuming an existing session, the chat consistently lands mid-conversation instead of at the end. Frequently the landing position is the todo widget. The bottom button briefly shows "↓ Latest" then flips to "↓ New activity"; clicking it only scrolls part of the way down, requiring repeated clicks.
Reproduces on desktop Chromium and Firefox. Worst on long sessions with tool output (many syntax-highlighted code blocks, todo widget with many items, image attachments) — i.e. exactly the sessions where landing at the end matters most.
Root cause
history_done(inapp-messages.js) callsscrollToBottom()once via a singlerequestAnimationFrame. That capturesscrollHeightbefore deferred content finishes laying out:tools.jspost-replayscrollHeightkeeps growing after the rAF pin. The scroll listener inapp.jsthen seesdistFromBottom > 150, setsisUserScrolledUp = true, and flips "↓ Latest" to "↓ New activity".forceScrollToBottom()has the same one-frame problem — every click reaches the bottom-at-this-instant, more content lays out below, button reappears, repeat.A second compounding issue: every per-tool render in
tools.js(TodoWrite, file edits, command results, plan cards, permission prompts, askUserQuestion, subagent activity, thinking, turn-meta) callsctx.scrollToBottom()unconditionally, including during history replay. Per-tool scrolls during replay re-anchor the viewport to whichever tool widget grew last — commonly the todo widget, which is tall when it has any unfinished items.Fix
Sticky-bottom mode in
app-rendering.js:ResizeObserveron#messages(and direct children, to catch child-only size changes from image loads / code highlighting) re-pinsscrollTop = scrollHeighton every height change while the flag is armed.ResizeObservercallback resets a timer; sticky-bottom only disarms after no resize for ~750ms. Long-settling sessions naturally extend the window. Also a hard 8s ceiling so we never lock the scroller indefinitely if some animation keeps firing.wheel,touchmove,keydownofPageUp/Home/ArrowUp— disarms immediately, so we never fight the user.Wired in:
app.jsscroll listener consultsgetStickyBottom()and ignores growth-induced scroll events while armed (soisUserScrolledUpdoesn't trip falsely when the ResizeObserver itself is moving the scroll).app-messages.jshistory_donearms sticky-bottom unless a navigate target (file-edit deeplink) is pending, in which case the existingscrollIntoViewpath handles it.forceScrollToBottom()now arms sticky-bottom instead of doing a one-frame pin, so "↓ New activity" reaches the true bottom in one click.Per-tool scroll calls in
tools.jsroute through amaybeScrollToBottom()helper that no-ops whilestore.get('replayingHistory')is true. Live behavior is unchanged — TodoWrite updates, tool runs, and assistant streams still auto-scroll exactly as before.Files
lib/public/modules/app-rendering.js— sticky-bottom mode (~110 lines)lib/public/app.js— scroll listener gate, import additionslib/public/modules/app-messages.js—history_donearms sticky-bottomlib/public/modules/tools.js—maybeScrollToBottomhelper, ~16 call sitesNo new dependencies. No new state on disk. No server changes.
Test plan
Notes
getStickyBottom()/armStickyBottom()/disarmStickyBottom()exports are kept narrow to make it easy to extend later (e.g. dead-session todo widget compaction, which is a separate follow-up — not this PR).