Repository navigation
[Bug]: Screen reader heading wrap in a long thread lands mid-conversation instead of the first or last turn #23760
Description
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.
- 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.
- In browse mode, press H until NVDA wraps past the last heading.
- 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:
- Confirm with NVDA that the wrap target is the edge of LegendList's render window.
- Check whether the first turn is reachable at all when it's in a history page that hasn't loaded yet.
- Check whether the 500 ms re-sort delay causes wrong reading order while navigating by heading.
- Check whether the timeline's scroll handling (
maintainScrollAtEnd,maintainVisibleContentPosition, thread position restore) fights a scroll the screen reader causes at either end. - 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.
- Keep the first and last loaded turns mounted. The timeline already passes
alwaysRenderto 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. - 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.
- 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. - 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
shivamhwp commentedon Oct 27, 2026
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.