Skip to content

[Bug]: Returning to a thread that streamed in the background lands far above the latest message #13601

Description

@santiago-ramos-02

Before submitting

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

Related: #5903 describes this symptom broadly, and #12372 covers the case where the "Scroll to end" pill stays hidden. This issue isolates one cause confirmed at runtime. In this case the pill does show.

Area

apps/web

Steps to reproduce

  1. Open a thread whose history overflows the viewport, scrolled to the bottom.
  2. Send a prompt that streams a long reply, for example a 1,000-word answer.
  3. While it streams and the timeline follows it, switch to another thread without scrolling.
  4. Wait for the reply to finish, then switch back.

It depends on timing. It happens when the last scroll event before the switch fired while the follow scroll was still catching up with new output. During a long reply that is true for much of the stream.

Expected behavior

The thread opens at its latest message, because the timeline was following the stream when you left it.

Actual behavior

The thread opens where the viewport was when you left, often thousands of pixels above the end, with "Scroll to end" showing. In the recording below it lands 2,657 px above the end.

Impact

Minor bug or occasional failure

Version or commit

main @ 86054b6

Environment

Web client from vp run dev on Windows 11, Codex provider. The logic is provider independent and lives in the shared web timeline, so desktop is affected too.

Logs or stack traces

No errors. Logging each rememberTimelinePosition call during one streamed reply showed 68 writes of atEnd: false for the thread, although the timeline was following the stream and nothing scrolled it by hand. The last write before switching away was { atEnd: false, scrollOffset: 14876 }, and on return the restore scrolled to that offset.

Screenshots, recordings, or supporting files

timeline-before-fix.mp4

Workaround

Click "Scroll to end" or press End.

Cause

handleScroll in MessagesTimeline.tsx saves atEnd: resolveTimelineIsAtEnd(state) into the per-thread position cache. While a turn streams, maintainScrollAtEnd glides to the end, so new output can sit more than 40 px below the viewport until the follow scroll catches up. Scroll events in that window report "not at end".

ChatView already treats that gap as follow lag: onIsAtEndChange ignores isAtEnd === false while live follow is active. The remembered position does not, so the thread is stored as a reading position. On return, the restore effect in MessagesTimeline and the thread-switch effect in ChatView read atEnd: false and restore that offset instead of the end.

Activity

  1. juliusmarminge commented on Sep 25, 2026

    @juliusmarminge
    Member

    Triage: #13601

    Verdict: Confirmed bug. Not a duplicate. Not fixed on main.

    Confidence: High. The reported path matches the current source at 86054b6df2 (current HEAD, the commit in the report). Not reproduced in a browser here. The reporter's runtime log matches the code.

    What is wrong

    Returning to a thread that was following a stream can open thousands of pixels above the latest message, with "Scroll to end" visible. The timeline was following when you left. The session cache says it was a reading position.

    Cause

    While a turn is running, follow uses an animated glide (TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH in MessagesTimeline.tsx). New output can sit more than 40px below the viewport until that glide catches up. resolveTimelineIsAtEnd treats a gap larger than 40px as not at the end (MessagesTimeline.logic.ts).

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

    It does not look at live follow. onIsAtEndChange in ChatView.tsx does: while the follow latch is still held, isAtEnd === false is ignored, so the pill stays hidden and follow stays on. The cache does not get that exception. A row-length effect also calls handleScroll on every growth, which matches the report of 68 atEnd: false writes during one reply with no manual scroll. The last write (atEnd: false, scrollOffset: 14876) is what return uses.

    On return, ChatView treats atEnd !== false as follow (ChatView.tsx around the thread-switch effect). false selects free-scrolling and shows the pill. MessagesTimeline then restores scrollOffset or the saved row instead of scrollToEnd, and turns maintainScrollAtEnd off for that restore.

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

    Not a duplicate

    Issue Why it is different
    #5903 Same broad symptom (open or finish above the latest message). Still open. This report is one confirmed cause: follow lag saved as a reading position, pill visible.
    #12372 Opposite cache bug. Position saved as atEnd: true, viewport stranded, pill hidden until a nudge. Open, with #12376.
    #12222 Restore runs against the previous thread's rows and lands at the top. This bug restores the saved offset and shows the pill.

    Cross-link #13601 to #5903. Do not close it as a duplicate.

    Already fixed?

    No. Nothing after 86054b6df2 touches this path. #13602 (fix(web): reopen a thread that was following its stream at the end, same author, open, not merged) is the fix: remember atEnd: isAtEnd || liveFollowEnabled, plus a test where the list is 500px short of the end.

    Review note for #13602

    Do not start a second fix. The change matches the in-session rule, but liveFollowEnabled is React state and the follow latch updates a ref first. A scroll event before the re-render can still store atEnd: true after the user has left the end. Later scroll events usually overwrite that. A switch in that window would not.

    The first message of an empty thread also sets live follow while anchored end space keeps maintainScrollAtEnd off. OR-ing live follow would remember that as at-end and scrollToEnd on return. That is outside this repro (history already overflows, so send takes scrollToEnd, not anchoring).

    Next step

    Keep #13601 open. Label it a bug, link #5903 and #12372, and review #13602. Workaround until that lands: "Scroll to end" or End.

  2. added
    via-triageFiled through npx t3 triage
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    on Sep 25, 2026
  3. added a commit that references this issue on Sep 25, 2026
    fc46fe2
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