Skip to content

fix: _loadOlderMessages scrolls to bottom instead of preserving position#1219

Closed
jasonjcwu wants to merge 1 commit intonesquena:masterfrom
jasonjcwu:fix/scroll-older-messages
Closed

fix: _loadOlderMessages scrolls to bottom instead of preserving position#1219
jasonjcwu wants to merge 1 commit intonesquena:masterfrom
jasonjcwu:fix/scroll-older-messages

Conversation

@jasonjcwu
Copy link
Copy Markdown
Contributor

When _loadOlderMessages prepends older messages, the viewport snaps to the bottom instead of staying where the user was.

Two bugs compounding:

  1. Wrong scrollable container. Code used $("msgInner") for scrollHeight and scrollTop, but #msgInner has no overflow-y — it is a flex column. The actual scrollable container is #messages (.messages{overflow-y:auto}). Setting msgInner.scrollTop was silently ignored.
  2. renderMessages calls scrollToBottom at the end (ui.js:2552), which unconditionally scrolls #messages to the bottom and sets _scrollPinned=true. Since bug Portability #1 made the scroll-restore a no-op, the page landed at the bottom every time.

Fix:

  • Changed scroll restore target from $("msgInner") to $("messages").
  • Reset _scrollPinned = false after restoring the user position, so scrollToBottom does not re-fire on next tick.

When _loadOlderMessages prepends older messages, the viewport snaps
to the bottom instead of staying where the user was.

Two bugs compounding:
1. Wrong scrollable container. Code used `$("msgInner")` for scrollHeight
   and scrollTop, but #msgInner has no overflow-y — it is a flex column.
   The actual scrollable container is #messages (`.messages{overflow-y:auto}`).
   Setting msgInner.scrollTop was silently ignored.
2. renderMessages calls scrollToBottom at the end (ui.js:2552),
   which unconditionally scrolls #messages to the bottom and sets
   _scrollPinned=true. Since bug nesquena#1 made the scroll-restore a no-op,
   the page landed at the bottom every time.

Fix:
- Changed scroll restore target from `$("msgInner")` to `$("messages")`.
- Reset _scrollPinned = false after restoring the user position,
  so scrollToBottom does not re-fire on next tick.
@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Great bug diagnosis, @jasonjcwu! The two-bug root cause analysis is exactly right:

  1. Wrong scroll container#msgInner has no overflow-y so scrollTop assignments were silently no-ops. #messages is the actual scrollable container.
  2. scrollToBottom re-firesrenderMessages unconditionally calls scrollToBottom at the end, which overwrites any scroll restoration and sets _scrollPinned = true. Since bug Portability #1 made scroll-restore a no-op, the user always landed at the bottom.

The fix is minimal and surgical: change the scroll container reference to #messages and reset _scrollPinned = false after restoring the position so scrollToBottom doesn't undo the restore.

One thing to verify: after setting _scrollPinned = false, is there a subsequent tick or event that reads _scrollPinned and could trigger an unwanted scroll? If renderMessages sets up any post-render callbacks that check _scrollPinned, we'd want to confirm those also behave correctly in the restored-position case.

The test coverage in test_parallel_session_switch.py is a good addition. Does it cover both the scroll container fix and the _scrollPinned reset, or primarily the end-to-end behavior?

Clean fix overall — thanks for tracking this down!

@jasonjcwu
Copy link
Copy Markdown
Contributor Author

When _loadOlderMessages() prepends older messages to the conversation, the viewport snaps to the bottom instead of staying where the user was — making it impossible to read older messages by scrolling up.

Root cause (two bugs compounding)

1. Wrong scrollable container. The code used $('msgInner') for scrollHeight/scrollTop, but #msgInner has no overflow-y — it is a flex column inside the DOM tree. The actual scrollable container is #messages (.messages{overflow-y:auto}). Setting .scrollTop on msgInner was silently ignored because the element never scrolls.

2. renderMessages() overrides scroll position. At the end of renderMessages() (ui.js:2552), the existing code unconditionally calls scrollToBottom(), which scrolls #messages to the bottom and sets _scrollPinned=true. Since bug #1 made the scroll-restore a no-op, the page landed at the bottom every time.

Fix

  • Changed scroll restore target from $('msgInner') to $('messages') (the real scrollable container).
  • Reset _scrollPinned = false after restoring the user's position, so scrollToBottom() doesn't re-fire on a subsequent render.

Safety analysis

All code paths that read _scrollPinned after the restore:

Location Reads Fires? Analysis
Scroll event handler (ui.js:600) nearBottom → _scrollPinned ⏳ next user scroll Correctly recalculates pinned state
Scroll-to-bottom button (ui.js:602) display=_scrollPinned?'none':'flex' ✅ immediately Actually helpful — shows "scroll to bottom" button
renderMessages() (ui.js:2718-2721) scrollToBottom() Next _loadOlderMessages run covered by our restore logic
Streaming callbacks (ui.js:2865, 3115, 3155, 3158) scrollIfPinned() / if(_scrollPinned) Not in-play during history loading (no active stream)
Queue card open (ui.js:1282, 1471) scrollToBottom() Separate code path, 360ms setTimeout
requestAnimationFrame (ui.js:2724) highlightCode/addCopyButtons next frame Does not read _scrollPinned at all

No unwanted scrolls are triggered.

Tests

2 new tests in TestScrollPositionPreservation (35 total in the suite):

  • test_uses_correct_scrollable_container — asserts $('messages') is used and $('msgInner') is absent from _loadOlderMessages
  • test_resets_scroll_pinned_after_restore — asserts _scrollPinned = false appears after the scrollTop restore, ensuring correct ordering

35/35 tests passing.

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Thanks for the thorough follow-up, @jasonjcwu! The detailed root cause analysis with the two-bug breakdown (wrong container + scrollToBottom() override) and the safety analysis table are exactly what's needed for a change touching scroll behavior.

The fixes are minimal and targeted:

  • Switching from $('msgInner') to $('messages') for the actual scrollable container is the correct fix — setting scrollTop on a non-scrolling element was a silent no-op.
  • Resetting _scrollPinned = false after restoring position prevents the next renderMessages() call from snapping back to the bottom.

The safety analysis confirms no downstream scrollIfPinned() or streaming callbacks fire during history loading, which covers the main regression risk.

The 2 new tests in TestScrollPositionPreservation — checking both the correct container reference and the ordering of _scrollPinned = false after scrollTop restore — are well-targeted. 35/35 passing.

This looks ready for maintainer review and merge. Clean fix with solid coverage. 🎉

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Merged in v0.50.237 via #1243. Thank you @jasonjcwu! 🎉

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants