[BUG][a11y] Desktop app: virtualized transcript unmounts what the screen reader is reading (root cause found + working workaround)

Status Open
Maintainer reply None cached
Activity 1 comment · opened Oct 3, 2026

Summary

  • In Claude Desktop, in both the Code and Chat tabs, the transcript keeps only the messages near the visible area in the DOM. A screen reader reads the content with its own cursor, which the app does not know about: the message being read gets unmounted, and reading jumps to the top of the conversation or into an old reply.
  • The cause is in the virtualizer (rocksteady): its windowing is purely geometric, and nothing protects what a screen reader is reading.
  • The virtualizer can already mount the whole conversation: that is the print mode of the Chat tab list. A screen reader mode, like the one in the Claude Code CLI, that extends the render window to the whole conversation in both lists would be enough. That is exactly what the workaround below produces.
  • A workaround works: I have used it for a full day on large Code and Chat sessions, with no jumps left and no noticeable slowdown. It has to be run by hand in DevTools every time the app starts, so it is no substitute for a fix. For those who just want to install it: https://github.com/thyhellcat/claude-desktop-stable-feed.

I am blind and use JAWS every day. The problem also happens with NVDA, less severely. It was already reported in #83167, which the stale bot closed with no reply from the team.

Environment

| | |
|---|---|
| App | Claude Desktop 2.19675.0.0 (MSIX), Code and Chat tabs |
| OS | Windows 11 Pro |
| Screen readers | JAWS 2026; NVDA also affected, less severely |

Steps to reproduce

  1. Open a conversation that contains a few long replies, in the Code or Chat tab.
  2. Read it with a screen reader.

Expected: reading stays where you left it.

Actual: as soon as part of a message leaves the visible area, reading jumps to the top of the conversation or into an old reply. It happens all the time, even while typing in the prompt box without reading anything. While a reply is being generated, it is worse: the jumps keep coming, and the reply cannot be read while it is being written. After switching sessions, reading starts again from the top of the page. The "Load earlier" and "scroll to bottom" controls do not help: they move the render window, which is precisely what breaks reading.

Root cause

Both lists use the rocksteady virtualizer (data-rocksteady-sizer, data-rs-index). The Code tab list is a separate copy, in the c1c1ec7b9-*.js chunk (data-transcript-arm="legacy"). The Chat tab list is in shared-3-*.js. I analyzed the shipped bundle with the help of Claude Code. The measurements below come from the Code tab: they rely on the virtualizer's built-in trace (window.__rsTrace = []) and on a log of focus and scroll events.

  1. Windowing is purely geometric. Rows outside the visible height, plus a 600px margin (overscanTop / overscanBottom), are unmounted. The initial window uses overscanTop: 0. In a session with long replies, only 4 rows out of 116 were in the DOM.
  2. Nothing protects what a screen reader is reading. keepMountedRef covers only three cases: the row that holds DOM focus, the rows of a text selection (when its two ends are fewer than 32 rows apart), and pinned rows. A screen reader's reading cursor moves neither DOM focus nor the selection.
  3. The view jumps. The log shows the feed scroll going from 8155 to 0 with no scroll call from the page, while focus stayed in the prompt box. Right after, 11 rows are unmounted and 13 mounted. The content changes under the screen reader, which re-syncs somewhere else.
  4. Switching sessions makes it worse. The virtualizer re-reads the visible height only through its ResizeObserver, so only when the container's size changes. When a session is reopened, it restores a saved height. After that, a plain scroll updates the total content height but not the visible height. The two values no longer match. In "follow the end" mode, the window then starts at the bottom of the conversation: in our trace, rows 0 to 72 were unmounted at once.

A "mount everything" path already exists

The Chat tab list has a print mode. On beforeprint, it adds every index to the set of rows kept mounted, so that a printout contains the whole conversation:

// de-minified for reading; `rO` is its name in shared-3-BPHZydi5.js
function rO() {
  const [t, n] = useState(false);
  useEffect(() => {
    const on = () => n(true), off = () => n(false);
    window.addEventListener("beforeprint", on);
    window.addEventListener("afterprint", off);
    ...
  }, []);
  return t;
}
// in the list: ke = rO(); keep = ke ? every index : focused/selected/pinned rows

Mounting the whole conversation is therefore already a state the virtualizer supports. The Code tab copy does not have this mode, and neither list mounts everything for screen readers.

Proposed fix

  1. A screen reader mode in the desktop app. The Claude Code CLI already has one (--ax-screen-reader), but it only covers the terminal. This one would turn on automatically when a screen reader is detected: Electron exposes app.accessibilitySupportEnabled and the accessibility-support-changed event in the main process, and the desktop bridge can pass that flag to the page. Since detection is never perfect, it should also be possible to turn it on by hand in the settings, rather than through yet another button in the conversation.
  2. In this mode, extend the render window to the whole conversation, in both lists, for example with an infinite margin (overscanTop / overscanBottom). This is the state the workaround produces, tested in real use. Nothing changes visually: rows outside the visible area stay off screen.

The print mode path (every index in the set of rows kept mounted) may be simpler, but it has not been tested in a live page. The window then stays narrow, and the rows outside it are only kept in addition. It would need checking that the spacers place them correctly during scrolling and during generation, and that the scroll corrections ("follow the end", restore after a session switch) no longer move the view.

  1. In this mode, put focus on the last message (the role="article" that is already there) when a session is opened or switched to. The screen reader then starts at the latest reply instead of the top of the page. This is what #71470 asked for ("jump to latest response"), with no shortcut to add.
  2. Independently of this mode: when the virtualizer restores a saved height on session reopen, re-read the real height before computing the window (point 4 of the root cause).

How the workaround relates. The workaround produces the same result as points 2 and 3, from the outside. Since it cannot change the list, it makes the virtualizer believe the visible area is huge: the render window then covers the whole conversation, and the virtualizer mounts every row by itself. Its watchdog only compensates for point 4. In the app, there is no need to fake a measurement: widening the window is enough.

Cost. With the workaround, every row stays mounted all the time. Over a full day of real use, with large Code and Chat sessions, I noticed no slowdown.

Workaround

To install it easily, with a step-by-step explanation: https://github.com/thyhellcat/claude-desktop-stable-feed.

In short, to reproduce: set {"allowDevTools": true} in %APPDATA%\Claude\developer_settings.json. Then, in the Claude window, press Ctrl+Alt+I, paste the script in the console, and press Enter. It can also be saved as a DevTools snippet and run with Ctrl+P then !name. Full commented source: https://github.com/thyhellcat/claude-desktop-stable-feed/blob/main/stable-feed.js.

How it works: the script makes the transcript scroller report a clientHeight far larger than its content, as an own property of that one element. It raises scrollHeight by the same amount, so the maximum scroll and the "at bottom" test are unchanged. The virtualizer then mounts every row by itself, and React keeps them live. Unlike the removeChild patch from #83167, nothing is detached, duplicated or reordered: expandable outputs still expand, and tasks still open. A watchdog forces a re-measure when a list is incomplete (after a session switch). On a session switch, the script also puts focus on the last message.

__stableFeed.status() reports, for each list: complete, the first and last mounted index, and the spacer heights. With the script running, my sessions stay at complete: true (for example 107 rows, 0 to 106, spacers down to the 48px padding).

The script depends only on the page DOM and the virtualizer, which are the same on every platform. It should therefore also work in the macOS app, with the equivalent developer_settings.json file and the Mac DevTools shortcut. I have no Mac to check: feedback from a VoiceOver user would be welcome.

Related

Since the virtualized list shipped, at least four blind users have reported these jumps or their consequences, with JAWS on Windows and VoiceOver on macOS (#83167, #87977, #96368, #71470, #70425).

  • #83167: the original report, with a workaround (closed by the stale bot, no reply from the team).
  • #70425: making Claude Code usable out of the box with a screen reader; its heading-navigation point requires the content to be in the tree (closed by the stale bot).
  • #71470: "jump to latest response" for screen reader users (closed by the stale bot).
  • #87977: VoiceOver cursor jumps in the macOS desktop app (open). Very likely the same cause, on another platform.
  • #86697: same symptom in the VS Code chat panel (open).
  • #96368: VoiceOver sent back to the top of a reply (open). Possibly related.

@bcherny, I'm mentioning you because you followed this topic on #70425, and you shipped screen reader support for the VS Code transcript (#80163): you know what a fix like this changes in daily use. The previous reports about the desktop app were closed by the bot before they reached the right person. If you could forward this one to whoever owns the desktop app transcript, thank you very much. I'm available to test a fixed build.

cc @ampage87 @Matthew2244 @DaChinski @w9fyi @DCA11Y @bretthalle

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗