[BUG][A11y] VS Code extension: chat transcript is a single unnavigable text block for screen reader users (no roles, names, headings, or live regions)

Status Open
Reported on v2.1.233
Maintainer reply None cached
Activity 0 comments · opened Aug 15, 2026

Summary

In the Claude Code VS Code extension, the chat transcript renders as nested plain div elements with no ARIA roles, no accessible names, and no headings. WebKit therefore coalesces the entire conversation into one static text node. For VoiceOver users this means the transcript cannot be navigated message by message, new output is never announced, and the VoiceOver cursor goes stale until the user exits and re-enters the webview frame.

This is a complete loss of transcript access for screen reader users. The conversation is readable only as one undifferentiated block, always starting from the top.

Environment

  • Extension: Anthropic.claude-code 2.1.233 (darwin-arm64)
  • VS Code: 1.133.0 (arm64)
  • macOS: 26.6.1 (build 25G76)
  • Screen reader: VoiceOver

Symptoms

  1. The transcript is announced as a single block of text. There is no way to move between individual messages.
  2. Pressing VoiceOver Left Arrow from the chat input moves to the top of the transcript rather than to the most recent message.
  3. New and streaming assistant output is never announced. The only way to read new content is to move focus out of the webview frame and back in, which also resets reading position to the top.

Root cause

All three symptoms share one cause. The transcript markup carries no accessibility semantics.

From webview/index.js in the shipped extension (minified, reformatted for readability):

<div ref={r} tabIndex={-1} className={`${ni.messagesContainer} ${ni.stickyMode} ...`}>
  {turns.map((turn, st) => (
    <div className={`${ni.turn} ...`} key={`turn-${st}`}>
      {turn.items.map(...)}          // each renders <div className={ni.message}>
    </div>
  ))}
</div>

The container is a bare div. Each turn is a bare div. Each message is a bare div. None carry role, aria-label, or a heading.

Verified across the entire 4.7 MB webview bundle:

  • No role="log", role="article", role="feed", role="list", role="listitem", role="region", or role="status" appears anywhere.
  • Only five aria-live regions exist, and all five belong to the embedded Monaco editor (monaco-status, monaco-alert, quick input counts, hover contents, editor message block). The chat UI itself has none.
  • The stickyHeader class is CSS-only sticky positioning. It is not a heading element.
  • The only aria-label values in the chat UI are on buttons and on the composer (role="textbox", label "Message input").
  • Headings exist in the bundle but only in onboarding, settings, and empty state UI, not per message.

How this maps to each symptom

Single block: With no articles, list items, or headings to establish boundaries, there is nothing in the accessibility tree for VoiceOver to navigate between. The turns collapse into one text node.

VoiceOver Left Arrow lands at the top: VoiceOver Left Arrow moves to the previous element in the accessibility tree. Because the whole transcript is one element, the previous element is the entire transcript, and the cursor is placed at its start. There is no per-message element that could receive the cursor, so "most recent message" is not currently expressible as a navigation target.

Stale cursor requiring a frame exit: With no live region in the chat UI, content changes fire no announcement. Because React replaces content inside the container during streaming, the VoiceOver cursor is invalidated. Leaving and re-entering the frame forces WebKit to rebuild the accessibility tree, which is why that workaround works and why reading always restarts at the top.

No workaround exists in the extension today

  • All 23 contributed commands were enumerated. There is claude-vscode.focus (focuses the input) and claude-vscode.blur, but no command that moves focus into the transcript or to the most recent message.
  • No accessibility or screen reader setting exists in the 208 KB settings schema.
  • The VoiceOver rotor is empty for this view because there are no headings or landmarks to populate it.

The only escape hatch is Claude Code: Open in Terminal, which bypasses the webview entirely.

WCAG conformance

  • 1.3.1 Info and Relationships (Level A): message boundaries and message authorship are conveyed visually only and are not programmatically determinable.
  • 4.1.2 Name, Role, Value (Level A): the transcript and its messages expose no role and no accessible name.
  • 4.1.3 Status Messages (Level AA): assistant responses appear without receiving focus and are not announced.

Proposed fix

The change is additive and does not affect visual presentation.

1. Give the transcript container a role and a name

<div
  ref={r}
  tabIndex={-1}
  role="log"
  aria-label="Conversation transcript"
  aria-busy={isStreaming}
  className={...}
>

aria-busy during streaming lets assistive technology defer processing until the response settles.

2. Make each message its own labelled element

<div
  className={`${ni.message} ...`}
  role="article"
  tabIndex={-1}
  aria-label={`${authorName}, message ${index + 1} of ${total}`}
>

This alone resolves symptoms 1 and 2. Once each message is a discrete element, VoiceOver Left Arrow from the composer naturally lands on the last message rather than on the whole transcript.

3. Add a visually hidden heading per turn

<div className={ni.turn} role="group" aria-labelledby={`turn-${st}-heading`}>
  <h3 id={`turn-${st}-heading`} className={visuallyHidden}>
    {authorName}, {timestamp}
  </h3>
  ...
</div>

This populates the VoiceOver rotor so users can jump between turns by heading, which is the primary navigation method for long transcripts.

4. Announce completion, not streaming

Important: do not put aria-live on the streaming container. Every token would re-announce and the result would be worse than silence.

Instead use a dedicated visually hidden polite region that announces discrete events only:

// announcement is set to a short discrete string such as
// "Claude replied.", "Running Bash.", or "Permission required."
<div role="status" aria-live="polite" className={visuallyHidden}>
  {announcement}
</div>

Set this on response completion, tool use, and permission prompts.

5. Add a command to reach the transcript

Add claude-vscode.focusLastMessage ("Claude Code: Focus Most Recent Message") that moves DOM focus to the last role="article" element, and give it a default keybinding. This complements the existing claude-vscode.focus and gives keyboard and screen reader users a direct, reliable path into the transcript from the composer.

Acceptance criteria

  • VoiceOver Left Arrow from the composer lands on the most recent message, not the top of the transcript.
  • VoiceOver Left and Right Arrow move between individual messages.
  • Each message announces its author and position, for example "Claude, message 12 of 14, article".
  • The VoiceOver rotor lists turns as headings.
  • Completion of an assistant response is announced once, without announcing every streamed token.
  • Reading the transcript no longer requires exiting and re-entering the webview frame.

Notes on verification

This analysis is source level, derived by reading the shipped webview/index.js render code and auditing ARIA usage across the bundle. The absence of roles, names, headings, and live regions is confirmed directly from the shipped code. The precise way WebKit coalesces the resulting tree was inferred from that markup rather than captured from an accessibility inspector session.

Related

Issue #85593 reports that the transcript, sidebar conversation list, and scheduled tasks are absent from the accessibility tree in the macOS desktop app. That is a different surface and a different implementation, but it is the same defect class: transcript content present visually and missing from the accessibility tree. The two together suggest the gap is systemic across surfaces rather than specific to the webview.

View original on GitHub ↗