fix(ui): preserve scroll position in messages.Model on resize; thread.Model fix partial - #239
Conversation
Narrowing or widening the messages/thread pane rewraps the render cache and jumps the scroll position to an unrelated spot; a bottom-anchored viewport also loses its pin. These tests fail against the current implementation.
Both panes stored scroll position as a raw line count into a width-dependent wrapped render cache. A width change rewraps every entry, so the same line count pointed at unrelated content afterward. Re-anchor to the entry that was visible before the rebuild, re-pin to the bottom when the viewport was bottom-anchored, and account for the thread pane's parent-message prefix (not itself a cache entry).
|
@gammons Please review this and let me know if there are any blockers in merging this. I would be happy to update. |
|
Thanks for the patience on the review, and sorry it sat. The diagnosis here is exactly right and unusually well written up — "a raw line count into a cache that gets rebuilt at a new width" is the bug, the entry+offset anchor is the correct fix, and the four-way test split (narrow / widen / bottom-pinned / top) is the right shape. Verified on your branch merged with current
The helper itself is byte-identical between the two models, and the bottom-pin and height-clamp branches are genuinely parallel. That's the part AGENTS.md cares most about and you got it right. But there's a case of the same bug still in the thread pane, and it's the one your
The parent shrinks to 9 lines on widening, line 40 is now deep in the reply list, and the viewport lands on reply 7. That is precisely the "same number, unrelated content" symptom this PR closes, one region of the pane earlier. Long opening messages are ordinary, so this is reachable in normal use. The PR description frames the Which brings me to the test. That matters more than the usual "please add a test", because of this in the description:
That's the right methodology — it's what I'd ask for — but it isn't true for this one, and the gap it left is where the remaining bug is. Worth checking how that slipped: if the revert was of the whole fix rather than the parent-prefix branch specifically, a 0-offset test would still look like it failed for the right reason in a batch run. Two smaller things from the same audit, both uncovered:
Both are guards against a count change racing a width change. Not bugs today; just deletable without signal. One divergence worth recording even though I can't reach it. The gate that decides whether any of this runs is not parallel between the models:
Marking changes requested, on the strength of the parent-prefix case alone — it's the same bug class the PR closes, it's reproducible, and the test that should cover it is vacuous. Either extend the The rest of the work is good and I'd like to take it. |
anchorOK is false whenever the viewport sits inside the parent-message prefix, and the resize-anchor switch has no case for that -- the pre-resize YOffset carries through unchanged. Out of scope for this PR to keep it reviewable; tracked as gammons#254. TestScrollAnchorAtTopOfThreadPreservedAcrossWidthResize only covered the YOffset==0 boundary, which can't distinguish fixed from broken. Narrowed its doc comment to say so, and added a characterization test pinning the current (known-limited) behavior at a non-zero offset inside the parent, so a future fix has a test to flip.
|
Thanks for the thorough review, and sorry for overlooking the parent-prefix case. You're right it's the same bug class, one region earlier. Filed #254 to track it with a repro, rather than fixing it here to keep this PR manageable. Also fixed the vacuous top-of-thread test — it only proved the If you have any further comments or suggestion. I would be happy to address. |
|
Thanks for filing #254 and for re-scoping the description; that's a clean way to handle the parent-prefix case, and I'm happy for it to land separately. The test changes you describe haven't reached the PR, though. The branch is still at |
|
My bad, I forgot to push the changes to remote. Thanks for noticing. I have updated the PR and I think it should be good to review this time. |
|
Got it, thanks.
Merged with current Two optional items from last round, not blocking:
Happy to take those in a follow-up. Marking ready to merge. |
Root cause
Both
messages.Modelandthread.Modelstore scroll position as a raw absolute line count into a fully flattened, word-wrapped render cache (yOffsetin the messages pane,vp.YOffset()in the thread pane). On any pane-width change, that cache is fully rebuilt at the new width — every message/reply re-wraps, changing every entry's line height and therefore what any given line number points at. The old raw line count was reused verbatim afterward (only clamped into the new valid range), so after a resize the same number pointed at unrelated content. That's the jump.Approach
Added
entryAndOffsetForLine(line)to both models: given a flattened line number, it returns which cache entry contains it and the offset within that entry. Using this:entryOffsetsthere covers replies only — the parent message block is a content prefix, not a cache entry — soentryAndOffsetForLinereturns an explicitokbool for "this line is inside the parent prefix" vs. "entry 0". This PR handles the bottom-anchored and entry-anchored cases only; anchoring while scrolled inside the parent prefix itself is a known gap, scoped out of this PR — see "Known limitation" below.Known limitation (scoped out, tracked separately)
When the thread-pane viewport is scrolled to a line inside the parent-message prefix (not yet showing any reply),
anchorOKisfalseand the resize-anchor switch has no case for it — the pre-resizeYOffsetcarries through unchanged, which can land on an unrelated reply after a large enough width change. This has been postponed to fix in another PR.Testing
Added regression tests in
internal/ui/messages/model_test.goandinternal/ui/thread/model_test.gocovering:YOffset == 0staying at0across a resize.Each test asserts against the actual rendered view output (via a unique marker embedded in each message/reply) rather than against the internal helper the fix introduces, so a bug inside that helper can't cancel itself out between the "before" and "after" measurements.
Every test was confirmed non-vacuous by temporarily reverting its corresponding piece of the fix and observing the test fail with the expected mismatch, then restoring the fix and confirming it passes again — with one exception, caught in review: the
YOffset == 0test above cannot distinguish fixed from broken, since 0 clamps to 0 either way. Its doc comment now says so plainly, and the new characterization test covers the case it was mistakenly thought to cover.closes #238