Selecting a thread put you at the newest message. Anything unread above that sat off the top of the pane with nothing to announce it — and by the time you scrolled up to find it, the auto-mark-read timer had marked the whole thread read.
The pane now opens on the oldest message that was unread when the thread was opened, falling back to the newest when the thread has already been read.
@mbunkus's out-of-order case is what rules out guessing at a position: a participant whose server could not connect for hours delivers a message long after it was written, and it lands in the middle of a conversation that has already moved past it. "Second to last", or any fixed offset from the end, finds nothing there. Reading the unread set is the only thing that does.
Two cases leave the pane where it is:
a single message, which is already the whole pane
the first unread being the first message, where the top of the pane shows it anyway together with the subject — scrolling to it would push the subject off for nothing
It reads the set captured when the thread was opened rather than live $seen, for the same reason expansion does (#69): the mark-read timer must not change the shape of what you are looking at. That also makes the landing stable, since everything above the first unread message is a collapsed row of fixed height and nothing up there reflows after the scroll.
Verified
Against the mock, which grows a thread that reproduces the report: seven messages, the unread one second, four more behind it.
new
old
unread message mid-thread
scrollTop 158, unread message flush at the top of the pane
scrollTop 445, with 287px of it — header, sender and unread bar — above the fold
first message unread
scrollTop 0, subject in view
would have scrolled 333
thread already read
newest message, unchanged
same code path
npm run typecheck clean; 293 web tests and 77 server tests pass, including six new ones covering the branches above.
Closes #87.
Selecting a thread put you at the newest message. Anything unread above that sat off the top of the pane with nothing to announce it — and by the time you scrolled up to find it, the auto-mark-read timer had marked the whole thread read.
The pane now opens on the oldest message that was unread when the thread was opened, falling back to the newest when the thread has already been read.
@mbunkus's out-of-order case is what rules out guessing at a position: a participant whose server could not connect for hours delivers a message long after it was written, and it lands in the middle of a conversation that has already moved past it. "Second to last", or any fixed offset from the end, finds nothing there. Reading the unread set is the only thing that does.
Two cases leave the pane where it is:
- a single message, which is already the whole pane
- the first unread being the first message, where the top of the pane shows it anyway together with the subject — scrolling to it would push the subject off for nothing
It reads the set captured when the thread was opened rather than live `$seen`, for the same reason expansion does (#69): the mark-read timer must not change the shape of what you are looking at. That also makes the landing stable, since everything above the first unread message is a collapsed row of fixed height and nothing up there reflows after the scroll.
### Verified
Against the mock, which grows a thread that reproduces the report: seven messages, the unread one second, four more behind it.
| | new | old |
| --- | --- | --- |
| unread message mid-thread | `scrollTop` 158, unread message flush at the top of the pane | `scrollTop` 445, with 287px of it — header, sender and unread bar — above the fold |
| first message unread | `scrollTop` 0, subject in view | would have scrolled 333 |
| thread already read | newest message, unchanged | same code path |
`npm run typecheck` clean; 293 web tests and 77 server tests pass, including six new ones covering the branches above.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
**Merged** 2026-08-27 as coffey-labs/ihasmail@32227722a7f1
<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 #87.
Selecting a thread put you at the newest message. Anything unread above that sat off the top of the pane with nothing to announce it — and by the time you scrolled up to find it, the auto-mark-read timer had marked the whole thread read.
The pane now opens on the oldest message that was unread when the thread was opened, falling back to the newest when the thread has already been read.
@mbunkus's out-of-order case is what rules out guessing at a position: a participant whose server could not connect for hours delivers a message long after it was written, and it lands in the middle of a conversation that has already moved past it. "Second to last", or any fixed offset from the end, finds nothing there. Reading the unread set is the only thing that does.
Two cases leave the pane where it is:
It reads the set captured when the thread was opened rather than live
$seen, for the same reason expansion does (#69): the mark-read timer must not change the shape of what you are looking at. That also makes the landing stable, since everything above the first unread message is a collapsed row of fixed height and nothing up there reflows after the scroll.Verified
Against the mock, which grows a thread that reproduces the report: seven messages, the unread one second, four more behind it.
scrollTop158, unread message flush at the top of the panescrollTop445, with 287px of it — header, sender and unread bar — above the foldscrollTop0, subject in viewnpm run typecheckclean; 293 web tests and 77 server tests pass, including six new ones covering the branches above.🤖 Generated with Claude Code
Merged 2026-08-27 as coffey-labs/ihasmail@32227722a7
Rebuilt from: git history, session transcript.