Repository navigation
[Bug]: Returning to a long thread lands at the top or the middle instead of where I left it #14051
Description
Activity
- addedbugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.Issue needs maintainer review and initial categorization.
on Sep 28, 2026 Triage: #14051
Verdict: Confirmed bug for switching back to a thread you left at the bottom. Same cache bug as #13601, with a wider trigger than an in-flight stream. Not a duplicate. Not fixed on
main.Confidence: High for that switch-back path. The cited lines match
94f92a7and currentmain(d15210cd); nothing in between touches this code. Not reproduced in a browser here. The relaunch sentence is a different path, and the position cache cannot cause it.What is wrong
A long thread left at the latest message can reopen at the top or in the middle, with "Scroll to end" showing. The session cache stored a reading position even though live follow was still on.
Cause
handleScrollwrites the geometric end check straight into the per-thread cache:rememberTimelinePosition(listIdentityKey, { ...position, // DOM geometry includes the header and the virtualizer's layout adjustment. offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, scrollOffset: element.scrollTop, atEnd: isAtEnd,
isAtEndisresolveTimelineIsAtEnd: a gap of more than 40px counts as not at the end (MessagesTimeline.logic.ts). Nothing in that write checks that the user scrolled. LegendList callsonScrollfor layout movement, and a row-count effect callshandleScrollagain one frame afterrows.lengthchanges (MessagesTimeline.tsxaround 1102).Two layout cases can open that gap while the user is still sitting at the bottom:
- Rows are estimated at 90px (
estimatedItemSize={90}). A tool or markdown row that measures taller leaves the viewport more than 40px short until follow catches up. maintainScrollAtEndsetsfooterLayout: falseon purpose, so composer growth does not re-pin.
onIsAtEndChangeinChatView.tsx(around 5640) already ignoresisAtEnd === falsewhile the follow latch is held, so the pill stays hidden and follow stays on. The cache does not get that exception.On the next visit, the thread-switch effect (around 5724) treats
atEnd === falseas free-scrolling, turns live follow off, and shows the pill. Restore then scrolls to the saved row, or toscrollOffsetwhen that row is missing (MessagesTimeline.tsxaround 864). A missing row also finishes the restore immediately, so a later snapshot does not retry.Desktop uses this web timeline. Mobile
ThreadFeeddoes not userememberTimelinePosition. The provider does not matter.What the report gets wrong
The position cache is an in-memory
MapintimelineScrollAnchoring.ts. It dies with the renderer. A desktop relaunch cannot restoreatEnd: falsefrom the previous session. A cold open has no saved position, so ChatView follows the end and the timeline usesinitialScrollAtEnd/scrollToEnd. Opening in the middle after a relaunch is a real-looking symptom, but it is not this cache bug. The likely neighbor isscrollToEndagainst the 90px estimates on a tall first page (the last 10 user turns,INITIAL_THREAD_USER_TURN_LIMIT), if item-layout re-pin does not win. That needs its own trace.The pixel fallback is also narrower than the write-up.
scrollToOffsetuses the stored offset. A large offset from a longer list, applied to the short first page, clamps to the end of that page (the latest turns), not the top. It lands at the top or the middle only when the stored offset itself was small or mid-list, which is what a measurement gap or a list reset saves. The snapshot replacement inthreads.ts(around 453) does drop older pages. That is not, by itself, an at-end restore jumping to the top.Not a duplicate
Issue Why it is different #13601 Same cache write, confirmed while a stream's follow glide was behind. This report's repro is an idle thread: scroll to the bottom, switch away, switch back. Layout and footer growth can save atEnd: falsewithout a stream. Fix is the same PR.#5903 Umbrella symptom (open or finish above the latest message). Still open. This is one cause: a follow gap saved as a reading position, pill visible. #12372 Opposite cache bug. Position saved as atEnd: true, viewport stranded, pill hidden until a nudge.#12222 Restore completes against the previous thread's rows and sticks at the top. Open PR. This report restores a saved offset and shows the pill. Worth having if a switch still lands at the top after atEndis fixed.Cross-link #14051 to #13601 and #5903. Do not close it as a duplicate.
Already fixed?
No. #13602 (
fix(web): reopen a thread that was following its stream at the end, open, not merged) is the switch-back fix: rememberatEnd: isAtEnd || (liveFollowEnabled && isLiveFollowLatched() && !anchoredEndSpace). The latch is the ref ChatView already uses to ignore follow lag, and anchored end space is excluded so a first send held near the top is not stored as the end. That covers this report's idle-at-the-bottom case whenever follow is still latched, including footer growth and a measurement gap.It does not change cold open. A relaunch that still lands in the middle after #13602 is the estimate / re-pin path, not this cache.
Next step
Keep #14051 open. Label it
bug+accepted, and take offneeds-triage. Review #13602 rather than starting a second patch. Workaround until that lands: "Scroll to end" or End.- Rows are estimated at 90px (
- addedacceptedfeature request acceptedfeature request acceptedvia-triageFiled through npx t3 triageFiled through npx t3 triageand removedneeds-triageIssue needs maintainer review and initial categorization.Issue needs maintainer review and initial categorization.
on Sep 28, 2026
Before submitting
Area
apps/web
Steps to reproduce
It also happened right after relaunching the desktop app: the long thread I returned to opened in the middle.
Expected behavior
The transcript returns to where I left it: here, the bottom, on the latest reply.
Actual behavior
It opens at an unpredictable position: sometimes at the very top, sometimes in the middle, with the "Scroll to end" button shown. The result differs between attempts. To continue, I have to scroll all the way down.
Likely cause
From reading the code; the client does not log positions, so I have not confirmed which path fired in my case.
MessagesTimeline.tsxsaves the thread's position, includingatEnd, inhandleScroll(lines 1021–1046). It runs on every LegendListonScroll, including scrolls caused by layout, and one frame after every change torows(lines 1102–1105). Nothing checks that the user scrolled.atEndis true only within 40 px of the end (resolveTimelineIsAtEndinMessagesTimeline.logic.ts). If the content is briefly more than 40 px past the viewport while I sit at the bottom,atEnd: falseis saved. That can happen when a row is measured taller than its 90 px estimate, or when the composer footer grows:maintainScrollAtEnddeliberately does not re-pin for footer growth.ChatView.tsx(lines 5724–5739) turns live follow off and shows "Scroll to end", and the timeline restores the saved row. If that row is not in the loaded window, it falls back to the saved pixel offset (MessagesTimeline.tsx:873), which lands at the top or in the middle. After a relaunch the thread first loads only the last 10 turns, and a replacement snapshot drops older pages (packages/client-runtime/src/state/threads.ts:453).The "Scroll to end" button already ignores
isAtEnd=falsechanges that the user did not cause while following (ChatView.tsx:5640–5648). The position save has no such check.Suggested fix
Save
atEnd: falseonly after user scroll input (the same signal that turns live follow off), or keepatEnd: truewhile live follow is on.Impact
Minor bug or occasional failure
Version or commit
0.0.43-nightly.20260924.2200, again on 0.0.43-nightly.20260928.2375; code references are to main @ 94f92a7
Environment
Desktop app on Linux (NixOS, Wayland, niri); provider Claude (Opus 5.5)
Logs or stack traces
Screenshots, recordings, or supporting files
No response
Workaround
Scroll to the bottom by hand.