Opening an already-read conversation lands short of the newest message #89

Closed
opened 2026-08-27 13:44:16 +00:00 by jcoffey-dev · 1 comment
Owner

Noticed while verifying #88; it predates that change and is untouched by it.

When a conversation is opened and nothing in it is unread, the reading pane is supposed to land on the newest message — that is the fallback branch of threadScrollTarget, and it is what the pane did before #88 for every thread. Sometimes it lands short, and sometimes it does not move at all.

What I measured

Same thread (the seven-message mock fixture added in #88), opened twice after it had been fully read:

open scrollTop scrollHeight - clientHeight newest message's top, relative to the pane
first 0 135 522px down the pane
second 96 135

So it is timing-dependent: once it did not scroll at all, once it got within 39px of the bottom of the scroll range. Neither put the newest message at the top of the pane.

Why I think it happens

ThreadView.tsx:119 scrolls in an effect that fires on [threadId, messages.length > 0] — as soon as the thread's messages are in the store. The newest message is the only expanded one in an all-read thread, and its body has not laid out yet at that point: the HTML renders, the blocked-remote-images banner appears, and the content grows afterwards.

scrollIntoView({ block: "start" }) clamps to the scroll range at the instant it is called. Measured against the shorter, pre-layout scrollHeight, that clamp is smaller than the offset it is aiming for — hence 0 when there was nothing to scroll yet, and 96 when there was a little. Nothing re-runs once the content grows to 135.

The mechanism is inferred from the two measurements above rather than observed directly; that part wants confirming.

Why the #87 fix is not affected

When something is unread the pane targets the first unread message instead, and everything above it is a collapsed row of fixed height — no images, no HTML body, nothing that reflows after the scroll. The offset it aims for is stable, and it was measured landing flush at the pane top. It is only the all-read fallback, which targets the last message, that aims at an offset sitting underneath content still being laid out.

Impact

Cosmetic. The newest message is still on screen, just not at the top of the pane, and one scroll fixes it. Filing it so it is written down rather than rediscovered.

Rebuilt from: session transcript.

Noticed while verifying #88; it predates that change and is untouched by it. When a conversation is opened and **nothing in it is unread**, the reading pane is supposed to land on the newest message — that is the fallback branch of `threadScrollTarget`, and it is what the pane did before #88 for every thread. Sometimes it lands short, and sometimes it does not move at all. ### What I measured Same thread (the seven-message mock fixture added in #88), opened twice after it had been fully read: | open | `scrollTop` | `scrollHeight - clientHeight` | newest message's top, relative to the pane | | --- | --- | --- | --- | | first | 0 | 135 | 522px down the pane | | second | 96 | 135 | — | So it is timing-dependent: once it did not scroll at all, once it got within 39px of the bottom of the scroll range. Neither put the newest message at the top of the pane. ### Why I think it happens `ThreadView.tsx:119` scrolls in an effect that fires on `[threadId, messages.length > 0]` — as soon as the thread's messages are in the store. The newest message is the only expanded one in an all-read thread, and its body has not laid out yet at that point: the HTML renders, the blocked-remote-images banner appears, and the content grows afterwards. `scrollIntoView({ block: "start" })` clamps to the scroll range *at the instant it is called*. Measured against the shorter, pre-layout `scrollHeight`, that clamp is smaller than the offset it is aiming for — hence 0 when there was nothing to scroll yet, and 96 when there was a little. Nothing re-runs once the content grows to 135. The mechanism is inferred from the two measurements above rather than observed directly; that part wants confirming. ### Why the #87 fix is not affected When something *is* unread the pane targets the first unread message instead, and everything above it is a collapsed row of fixed height — no images, no HTML body, nothing that reflows after the scroll. The offset it aims for is stable, and it was measured landing flush at the pane top. It is only the all-read fallback, which targets the last message, that aims at an offset sitting underneath content still being laid out. ### Impact Cosmetic. The newest message is still on screen, just not at the top of the pane, and one scroll fixes it. Filing it so it is written down rather than rediscovered. <sub>Rebuilt from: session transcript.</sub>
Author
Owner

Correction to the evidence above, before #90 closes this.

The two readings I reported — scrollTop 0 on one open and 96 on another — came from a Chrome tab that was hidden. Chrome suspends rendering, requestAnimationFrame and ResizeObserver delivery in that state and clamps timers to 1s, so the layout the scroll was measuring against was stale in a way it never is for a real reader. The "did not move at all" reading was an artifact of that, and so was the non-determinism.

Re-measured in a visible tab, the behaviour is deterministic: eight opens of the same already-read thread, all eight landing at scrollTop 96 of a 135 range. 39px short, every time.

The mechanism in the original report holds up — the pane grows after the scroll and scrollIntoView has already clamped to the shorter range — with one detail I had wrong. It is not that the clamp is sometimes hit. The fallback aims at the last message, and no thread has enough content after it to lift it to the top of the pane, so the clamp is always what decides where the scroll ends: it is the full scroll range, measured before the images landed.

Correction to the evidence above, before #90 closes this. The two readings I reported — `scrollTop` 0 on one open and 96 on another — came from a Chrome tab that was hidden. Chrome suspends rendering, `requestAnimationFrame` and ResizeObserver delivery in that state and clamps timers to 1s, so the layout the scroll was measuring against was stale in a way it never is for a real reader. The "did not move at all" reading was an artifact of that, and so was the non-determinism. Re-measured in a visible tab, the behaviour is deterministic: eight opens of the same already-read thread, all eight landing at `scrollTop` 96 of a 135 range. 39px short, every time. The mechanism in the original report holds up — the pane grows after the scroll and `scrollIntoView` has already clamped to the shorter range — with one detail I had wrong. It is not that the clamp is *sometimes* hit. The fallback aims at the last message, and no thread has enough content after it to lift it to the top of the pane, so the clamp is always what decides where the scroll ends: it is the full scroll range, measured before the images landed.
This repo is archived. You cannot comment on issues.