Skip to content

fix(scroll): pin viewport to bottom on resume so deferred layout can't strand user mid-conversation#342

Merged
chadbyte merged 3 commits intochadbyte:mainfrom
akuehner:upstream-pr/fix-resume-scroll
Apr 29, 2026
Merged

fix(scroll): pin viewport to bottom on resume so deferred layout can't strand user mid-conversation#342
chadbyte merged 3 commits intochadbyte:mainfrom
akuehner:upstream-pr/fix-resume-scroll

Conversation

@akuehner
Copy link
Copy Markdown
Contributor

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 (in app-messages.js) calls scrollToBottom() once via a single requestAnimationFrame. That captures scrollHeight before deferred content finishes laying out:

  • Tool widgets render via tools.js post-replay
  • Markdown / syntax highlighting reflows 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, which is tall when it has any unfinished items.

Fix

Sticky-bottom mode in app-rendering.js:

  • A ResizeObserver on #messages (and direct children, to catch child-only size changes from image loads / code highlighting) re-pins scrollTop = scrollHeight on every height change while the flag is 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. Also a hard 8s ceiling so we never lock the scroller indefinitely if some animation keeps firing.
  • Real user input — wheel, touchmove, keydown of 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.

Files

  • lib/public/modules/app-rendering.js — sticky-bottom mode (~110 lines)
  • lib/public/app.js — scroll listener gate, import additions
  • lib/public/modules/app-messages.jshistory_done arms sticky-bottom
  • lib/public/modules/tools.jsmaybeScrollToBottom helper, ~16 call sites

No new dependencies. No new state on disk. No server changes.

Test plan

  • Resume a session that ends with a todo widget visible — view lands at the bottom of the conversation, not on the widget.
  • After resume, "↓ Latest" / "↓ New activity" button does not appear unless the user actually scrolls up.
  • Click "↓ New activity" — reaches true bottom in one click and stays.
  • User scrolls up during/after sticky-bottom arm — disarms immediately, no fight with the user.
  • File-edit deeplink (navigate target) still scrolls the target element into view (sticky-bottom is suppressed in that path).
  • Live session: TodoWrite updates, tool runs, assistant streams still auto-scroll as before.
  • PR fix(scroll): scroll to bottom when returning to app after backgrounding #324 visibilitychange behavior preserved (independent path).

Notes

  • Tested on a downstream fork before submitting.
  • The 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).

…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.
@chadbyte chadbyte closed this Apr 27, 2026
@chadbyte chadbyte reopened this Apr 27, 2026
@chadbyte chadbyte closed this Apr 27, 2026
@chadbyte chadbyte reopened this Apr 27, 2026
@chadbyte
Copy link
Copy Markdown
Owner

chadbyte commented Apr 27, 2026

Hi @akuehner , scroll fix itself looks great. But app-messages.js has unrelated changes that aren't in the description:

import { handleAgentsList, handleAgentFavoriteToggled } from './agent-picker.js';

agent-picker.js doesn't exist on main, so this would break the client at load time. Looks like it got pulled in from another branch by accident.

I just added a CI check (.github/workflows/pr-checks.yml) that catches unresolved imports. Since this PR predates the workflow, I close/reopened it once to trigger a run — the failing checks above is from that. Output:

lib/public/modules/app-messages.js -> ./agent-picker.js  (unresolved relative import)

Could you drop the agent-picker import and the two related case blocks? Then it's good to merge. Thanks!

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.
@akuehner
Copy link
Copy Markdown
Contributor Author

See #345 for the follow-up issue documenting remaining scroll latency on long sessions and the proposed improvements (batched highlight, dead-session todo compaction, content-visibility, reduced page size).

@chadbyte chadbyte merged commit edea532 into chadbyte:main Apr 29, 2026
1 check passed
@github-actions
Copy link
Copy Markdown
Contributor

This issue has been resolved in version 2.36.1-beta.1 (main).

To update, run:

npx clay-server@2.36.1-beta.1

-- Clay Deploy Bot

Build anything, with anyone, in one place.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants