Skip to content

fix(a11y): announce that a reply is generating, and that one failed - #6135

Merged
timothycarambat merged 4 commits into
Mintplex-Labs:masterfrom
opensource-joe:a11y/announce-streamed-reply
Aug 19, 2026
Merged

fix(a11y): announce that a reply is generating, and that one failed#6135
timothycarambat merged 4 commits into
Mintplex-Labs:masterfrom
opensource-joe:a11y/announce-streamed-reply

Conversation

@opensource-joe

Copy link
Copy Markdown
Contributor

What and why

The assistant's turn is invisible to a screen reader from start to finish.

There is no live region anywhere in the frontend. No aria-live, no role="status", no role="log", no role="alert", no aria-atomic, in any .jsx or .js outside node_modules, across 589 files.

The pending state is the sharpest case. PromptReply renders it as a bare dot-falling animation:

<div className="mt-3 ml-1 dot-falling light:invert"></div>

That is purely visual, so a user who cannot see it is told neither that the assistant is working nor, later, that it has stopped. The error branch has the same problem from the other end: the turn ends and nothing announces that it ended badly.

I want to be clear that this reads as a gap rather than a position. The same codebase already carries 56 aria-label attributes plus aria-hidden, aria-checked, aria-pressed and aria-current, and sr-only is already used in PromptInput and Toggle. The intent is plainly there. This is one thing that was never wired up.

What changed

Two attributes and a string, both in ChatHistory/PromptReply/index.jsx.

Pending gains an sr-only role="status" carrying the text, and the animation is marked aria-hidden:

<span className="sr-only" role="status">
  {t("chat_window.generating_response")}
</span>
<div
  className="mt-3 ml-1 dot-falling light:invert"
  aria-hidden="true"
></div>

The text sits inside the status region rather than the region wrapping the animation, so it announces once on appearance instead of on every repaint.

Error gains role="alert". Alert rather than status, because a failed reply is the one case here that warrants interrupting the user: nothing else on the page changes to tell them the turn is over.

What this deliberately does not do

It does not announce the streamed reply text. Piping a token stream into a live region makes a screen reader re-read the growing partial message on every chunk, which is worse than silence.

Doing that properly means announcing the completed message once, and the completion boundary is not observable from inside PromptReply: this component re-renders on every chunk as reply grows, and is swapped for HistoricalMessage when the turn ends. So it belongs in ChatHistory, in a separate change. Happy to follow up with it if you want it, and happy to leave it if you would rather that stayed as it is.

Verification

npx prettier --check on both changed files pass
npx eslint on both changed files exit 0
npx vite build exit 0, built in 28s

What I have not done, stated plainly: I have not exercised this with a screen reader against a running instance. Producing a real stream needs an LLM provider credential I do not have. The change is two ARIA attributes and a translation string with no behavioural logic, and the reasoning above is about which region type fires when, so I would rather say that outright than imply a test I did not run. If a maintainer with a running instance wants to sanity-check it with VoiceOver or NVDA before merging, that seems entirely reasonable to me.

i18n

The string is added to locales/en/common.js only. i18n.js sets fallbackLng: "en", so the other 31 locales fall back rather than break. Say the word if you would prefer all 32 populated in this PR.

opensource-joe and others added 4 commits August 16, 2026 20:42
The assistant's turn is currently invisible to a screen reader from start to
finish. There is no live region anywhere in the frontend: no aria-live, no
role="status", no role="log", no role="alert" and no aria-atomic in any .jsx or
.js outside node_modules, across 589 files.

The pending state is the sharpest case. PromptReply renders it as a bare
dot-falling animation, which is purely visual, so a user who cannot see it is
told neither that the assistant is working nor, later, that it stopped. The
error state has the same problem for the opposite reason: the turn ends and
nothing announces that it ended badly.

This is a gap rather than a position. The same codebase already carries 56
aria-label attributes plus aria-hidden, aria-checked, aria-pressed and
aria-current, so the intent is clearly there.

Two changes, both in PromptReply:

- The pending branch gains an sr-only role="status" carrying the text, with the
  animation marked aria-hidden. The text sits in the status region rather than
  wrapping the animation so that it announces once, on appearance, instead of on
  every repaint.
- The error branch gains role="alert". Alert rather than status because a failed
  reply is the one case that warrants interrupting: nothing else on the page
  changes to signal the turn is over.

Deliberately NOT included: announcing the streamed reply text itself. Piping a
token stream into a live region makes a screen reader re-read the growing
partial message on every chunk, which is worse than silence. Doing it properly
means announcing the completed message once, and that belongs in ChatHistory
where completion is observable, not here. Happy to follow up with it if wanted.

The string is added to the en locale only; i18n.js sets fallbackLng "en", so the
other 31 locales fall back rather than break.
@timothycarambat
timothycarambat merged commit b09fee6 into Mintplex-Labs:master Aug 19, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants