Skip to content

[Bug]: Returning to a long thread lands at the top or the middle instead of where I left it #14051

Description

@Vantrongs

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/web

Steps to reproduce

  1. Open a long thread (several hundred messages, many tool calls) and scroll to the bottom.
  2. Switch to another thread.
  3. Switch back.

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.tsx saves the thread's position, including atEnd, in handleScroll (lines 1021–1046). It runs on every LegendList onScroll, including scrolls caused by layout, and one frame after every change to rows (lines 1102–1105). Nothing checks that the user scrolled.
  • atEnd is true only within 40 px of the end (resolveTimelineIsAtEnd in MessagesTimeline.logic.ts). If the content is briefly more than 40 px past the viewport while I sit at the bottom, atEnd: false is saved. That can happen when a row is measured taller than its 90 px estimate, or when the composer footer grows: maintainScrollAtEnd deliberately does not re-pin for footer growth.
  • On the next visit, 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=false changes that the user did not cause while following (ChatView.tsx:5640–5648). The position save has no such check.

Suggested fix

Save atEnd: false only after user scroll input (the same signal that turns live follow off), or keep atEnd: true while 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.

Activity

  1. added
    bugSomething is broken or behaving incorrectly.
    needs-triageIssue needs maintainer review and initial categorization.
    on Sep 28, 2026
  2. juliusmarminge commented on Sep 28, 2026

    @juliusmarminge
    Member

    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 94f92a7 and current main (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

    handleScroll writes 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,

    isAtEnd is resolveTimelineIsAtEnd: 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 calls onScroll for layout movement, and a row-count effect calls handleScroll again one frame after rows.length changes (MessagesTimeline.tsx around 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.
    • maintainScrollAtEnd sets footerLayout: false on purpose, so composer growth does not re-pin.

    onIsAtEndChange in ChatView.tsx (around 5640) already ignores isAtEnd === false while 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 === false as free-scrolling, turns live follow off, and shows the pill. Restore then scrolls to the saved row, or to scrollOffset when that row is missing (MessagesTimeline.tsx around 864). A missing row also finishes the restore immediately, so a later snapshot does not retry.

    Desktop uses this web timeline. Mobile ThreadFeed does not use rememberTimelinePosition. The provider does not matter.

    What the report gets wrong

    The position cache is an in-memory Map in timelineScrollAnchoring.ts. It dies with the renderer. A desktop relaunch cannot restore atEnd: false from the previous session. A cold open has no saved position, so ChatView follows the end and the timeline uses initialScrollAtEnd / scrollToEnd. Opening in the middle after a relaunch is a real-looking symptom, but it is not this cache bug. The likely neighbor is scrollToEnd against 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. scrollToOffset uses 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 in threads.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: false without 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 atEnd is 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: remember atEnd: 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 off needs-triage. Review #13602 rather than starting a second patch. Workaround until that lands: "Scroll to end" or End.

  3. added
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    and removed
    needs-triageIssue needs maintainer review and initial categorization.
    on Sep 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions