Opening an already-read conversation stopped 39px short of the bottom, every time. Everything was on screen and one scroll fixed it, but the pane was not where it meant to be.
Why
The scroll runs in an effect, which is too early. Message bodies are written into shadow roots by the child effects underneath it, and the images in those load later still — so the pane goes on growing after the scroll has happened, and scrollIntoView clamps to the scroll range as it stands the moment it is called.
The read-thread fallback aims at the last message, which no thread has the room to lift to the top, so that clamp is the whole of the range. Measuring it before the images landed measured it short.
What changed
The target is held against the top of the pane while the thread settles — a ResizeObserver over the scroller's children re-aligns it whenever one of them changes height.
The hold ends the instant the reader touches the pane (wheel, pointer, touch, or any key) and after two seconds regardless. A pane that re-scrolls under someone who has started reading is far worse than one that lands short, so it lets go on the first sign of them rather than waiting for the content to stop changing.
Verified
Against the mock, on the same already-read seven-message thread, eight opens each way:
scrollTop
range
short by
before
96 ×8
135
39px
after
135 ×8
135
0
The #87 cases are unchanged: an unread message mid-thread still comes to rest flush against the top of the pane (scrollTop 158, offset 0), and a thread whose first message is the unread one still stays at 0 with the subject in view. Scrolling or pressing a key during the hold leaves the pane exactly where it was put, both re-checked after 2.5s.
npm run typecheck clean, 293 web and 77 server tests pass. No new unit tests — the behaviour is layout timing, which jsdom does not model; threadScrollTarget's choice of target is already covered and unchanged.
The issue reported that the pane sometimes did not move at all. That was an artifact of my measuring in a background tab, where Chrome suspends rendering and clamps timers. In a visible tab the behaviour is the deterministic 39px above. The issue is real; that one observation in it was not.
Closes #89.
Opening an already-read conversation stopped 39px short of the bottom, every time. Everything was on screen and one scroll fixed it, but the pane was not where it meant to be.
### Why
The scroll runs in an effect, which is too early. Message bodies are written into shadow roots by the child effects underneath it, and the images in those load later still — so the pane goes on growing after the scroll has happened, and `scrollIntoView` clamps to the scroll range as it stands the moment it is called.
The read-thread fallback aims at the last message, which no thread has the room to lift to the top, so that clamp *is* the whole of the range. Measuring it before the images landed measured it short.
### What changed
The target is held against the top of the pane while the thread settles — a `ResizeObserver` over the scroller's children re-aligns it whenever one of them changes height.
The hold ends the instant the reader touches the pane (wheel, pointer, touch, or any key) and after two seconds regardless. A pane that re-scrolls under someone who has started reading is far worse than one that lands short, so it lets go on the first sign of them rather than waiting for the content to stop changing.
### Verified
Against the mock, on the same already-read seven-message thread, eight opens each way:
| | scrollTop | range | short by |
| --- | --- | --- | --- |
| before | 96 ×8 | 135 | 39px |
| after | 135 ×8 | 135 | 0 |
The #87 cases are unchanged: an unread message mid-thread still comes to rest flush against the top of the pane (`scrollTop` 158, offset 0), and a thread whose first message is the unread one still stays at 0 with the subject in view. Scrolling or pressing a key during the hold leaves the pane exactly where it was put, both re-checked after 2.5s.
`npm run typecheck` clean, 293 web and 77 server tests pass. No new unit tests — the behaviour is layout timing, which jsdom does not model; `threadScrollTarget`'s choice of target is already covered and unchanged.
### A correction to #89
The issue reported that the pane sometimes did not move at all. That was an artifact of my measuring in a background tab, where Chrome suspends rendering and clamps timers. In a visible tab the behaviour is the deterministic 39px above. The issue is real; that one observation in it was not.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
**Merged** 2026-08-27 as coffey-labs/ihasmail@24ee5025320a
<sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #89.
Opening an already-read conversation stopped 39px short of the bottom, every time. Everything was on screen and one scroll fixed it, but the pane was not where it meant to be.
Why
The scroll runs in an effect, which is too early. Message bodies are written into shadow roots by the child effects underneath it, and the images in those load later still — so the pane goes on growing after the scroll has happened, and
scrollIntoViewclamps to the scroll range as it stands the moment it is called.The read-thread fallback aims at the last message, which no thread has the room to lift to the top, so that clamp is the whole of the range. Measuring it before the images landed measured it short.
What changed
The target is held against the top of the pane while the thread settles — a
ResizeObserverover the scroller's children re-aligns it whenever one of them changes height.The hold ends the instant the reader touches the pane (wheel, pointer, touch, or any key) and after two seconds regardless. A pane that re-scrolls under someone who has started reading is far worse than one that lands short, so it lets go on the first sign of them rather than waiting for the content to stop changing.
Verified
Against the mock, on the same already-read seven-message thread, eight opens each way:
The #87 cases are unchanged: an unread message mid-thread still comes to rest flush against the top of the pane (
scrollTop158, offset 0), and a thread whose first message is the unread one still stays at 0 with the subject in view. Scrolling or pressing a key during the hold leaves the pane exactly where it was put, both re-checked after 2.5s.npm run typecheckclean, 293 web and 77 server tests pass. No new unit tests — the behaviour is layout timing, which jsdom does not model;threadScrollTarget's choice of target is already covered and unchanged.A correction to #89
The issue reported that the pane sometimes did not move at all. That was an artifact of my measuring in a background tab, where Chrome suspends rendering and clamps timers. In a visible tab the behaviour is the deterministic 39px above. The issue is real; that one observation in it was not.
🤖 Generated with Claude Code
Merged 2026-08-27 as coffey-labs/ihasmail@24ee502532
Rebuilt from: git history, session transcript.