Skip to content

[Bug]: Screen reader heading wrap in a long thread lands mid-conversation instead of the first or last turn #23760

Closed

Description

@nibarAhmed

Before submitting

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

Area

apps/desktop

Steps to reproduce

Setup: NVDA supports wrapping quick navigation but doesn't wrap by default. Install the Screen Wrapping add-on to turn it on.

  1. Turn on NVDA and open a long thread in the T3 Code desktop app, one with a "Load earlier turns" control or that scrolls well past one screen.
  2. In browse mode, press H until NVDA wraps past the last heading.
  3. Press Shift+H until NVDA wraps past the first heading.

Expected behavior

  • Wrapping forward past the last heading lands on the heading of the thread's first turn, even when earlier turns haven't been loaded yet. The timeline scrolls to the beginning of the conversation.
  • Wrapping backward past the first heading lands on the heading of the thread's last turn, and the timeline stays scrolled there.

Actual behavior

Wrapping lands on a message heading somewhere in the middle of the conversation, not on the first or last turn.

Confirmed by reading the code (not yet confirmed with NVDA):

  • The timeline is virtualized with LegendList and uses its default 250px draw distance (apps/web/src/components/chat/MessagesTimeline.tsx). Rows far from the viewport aren't in the DOM.
  • Each message's hidden "You said" or "Assistant said" heading (apps/web/src/components/chat/TimelineRowParts.tsx) is part of its row, so it only exists while that row is rendered.
  • Long threads load history in pages behind "Load earlier turns".
  • LegendList reuses absolutely positioned row containers and re-sorts them into chronological DOM order 500 ms after positions stop changing.

Likely cause: a screen reader can only wrap to headings that are in the DOM, so it lands on the first or last rendered message instead of the thread's real first or last turn.

To explore:

  1. Confirm with NVDA that the wrap target is the edge of LegendList's render window.
  2. Check whether the first turn is reachable at all when it's in a history page that hasn't loaded yet.
  3. Check whether the 500 ms re-sort delay causes wrong reading order while navigating by heading.
  4. Check whether the timeline's scroll handling (maintainScrollAtEnd, maintainVisibleContentPosition, thread position restore) fights a scroll the screen reader causes at either end.
  5. Check other virtualized lists, starting with the "Tool calls" list in work groups (apps/web/src/components/chat/TimelineWorkGroupRows.tsx), for the same problem.

Virtualization and history paging are there for performance and should stay. A fix needs to make the first and last turns reachable, including turns that haven't been fetched yet, and keep the scroll position where the screen reader lands, without rendering or loading the whole thread up front.

Related: #8535, which added a heading for each message. I have not tested or checked the mobile app, where messages don't have headings yet.

Additional note

I'm a screen reader user, and I'd be happy to contribute a fix. Below is a first exploratory approach, not a finished design. It depends on what the "To explore" checks find, so the details will likely change.

  1. Keep the first and last loaded turns mounted. The timeline already passes alwaysRender to LegendList for citations, position restore and fullscreen apps. Adding the first and last rows keeps their headings in the DOM, so wrapping can reach them. That's at most two extra rows, and the last one is usually already rendered.
  2. Make the thread's real first turn reachable when it's in a history page that hasn't loaded. Two options to discuss: load the earlier pages when the user reaches the start, or always send the first turn with the thread and show it above the "Load earlier turns" control. The second one changes what the server sends, so I'd want your direction before trying it.
  3. Keep the view where the screen reader lands. Check what NVDA does when it moves to a heading in an off-screen row. If the timeline's scroll handling pulls the view away, pause it for that move, or scroll the row into place with scrollToIndex.
  4. Verify with NVDA on a long seeded thread, and add a browser test that the first and last rows' headings are in the DOM while the list is scrolled to the middle.

If this approach doesn't fit how you'd like it solved, I'm open to others and happy to follow your direction.

Impact

Major degradation or frequent failure

Version or commit

T3 Code Nightly 0.1.2-nightly.20261022.3512

Environment

Windows 11 Pro (10.0.26200), T3 Code desktop app, NVDA 2026.2 with the Screen Wrapping add-on (NVDA doesn't wrap by default)

Logs or stack traces

Screenshots, recordings, or supporting files

No response

Workaround

Scroll to the top or bottom of the thread first, or move through it heading by heading, so the target rows render before navigating to them. This works, but it is slow in a long thread and not a good substitute for wrapping.

Activity

  1. added
    bugSomething is broken or behaving incorrectly.
    needs-triageIssue needs maintainer review and initial categorization.
    on Oct 22, 2026
  2. shivamhwp commented on Oct 27, 2026

    @shivamhwp
    Collaborator

    [Opus 5.5][Claude Code] on behalf of shivam.

    Fixed by #23780. The list now keeps messages in the page in the order you see them, so heading navigation wraps to the first or last turn.

    One gap is left from @nibarAhmed's retest: pressing a heading key within about a second of a long mouse-wheel scroll can land on the wrong message (12 of 22 tries). Pressing it again goes to the right place. While you scroll, the list waits until scrolling stops before it reorders, because reordering on every scroll event made scrolling slow. Keyboard navigation, switching threads and reloading worked every time.

    If the gap gets in the way in real use, please open a new issue and we'll take another look.

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

    bugSomething is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions