# fix\(web\): screen readers hear which background thread finished, even with the sidebar collapsed · PR \#23839 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23839)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: merged

Source repository: pingdotgg/t3code

Source branch: issue-watch/23811

Target branch: main

Head commit: 5d58f4c81b76704b7745d2c527635cbf0df1d32c

[Discussion and reviews](https://git.cafe/pingdotgg/t3code/pull/23839)

[Commits](https://git.cafe/pingdotgg/t3code/pull/23839/commits?format=markdown)

[Files changed](https://git.cafe/pingdotgg/t3code/pull/23839/diffs?format=markdown)

## Description

---

Refs #23811. Stacked on #23752, which makes the open thread announce its own status.

NVDA was tested by nibarAhmed on `ac463d5fbe` (results in the comments). VoiceOver was recorded on an earlier commit that still used `role="status"` (below). The 7 s clear added in `4ecebd3019` hasn't been run under a screen reader yet.

## Problem

Screen readers didn't hear a background thread finish, fail or ask for something when the sidebar was collapsed. A collapsed sidebar is `inert`, and the only live regions for those statuses were the labels on its rows. With the sidebar open you heard "Done" without the thread's name, and the wording differed from the open thread ("Pending Approval", "Input").

## Fix

- One polite live region now sits next to the sidebar, outside the inert part. For any thread except the open one it says `<status>: <thread title>`: "Needs approval" or "Needs your input" when it asks for something, and "Done", "Failed" or "Stopped" when a run ends.
- The region is a plain `aria-live="polite"` div with no `role="status"`. Orca 50 maps `role="status"` to a status bar in Chromium and drops its updates; a plain polite region is spoken. #23752 makes the same change for the open thread.
- A run's ending is said once, keyed on the run, the way #23752 does it for the open thread. Marking a thread unread doesn't replay it, and a second run is caught even if it finishes between two updates. A second approval or question of the same kind is announced too (a subagent's requests carry no id, so back-to-back ones from a subagent are said once).
- A queued message cancelled while another run works isn't announced as "Stopped". The shell then only reports that cancelled run, so the working run's own ending stays quiet in this case rather than being guessed. The app's completion notifications and sounds miss this ending for the same reason; fixing it means changing which run the server's thread shell reports as latest, which is out of scope here.
- With in-app notifications on (off by default), a background completion or request is heard twice: once from the toast, which sits in a polite live region, and once from this region. Muting this region then would drop what toasts don't cover ("Stopped", a second request in the same run, anything while the window isn't focused).
- Both regions clear their text 7 s after each announcement. NVDA read the leftover text again when a dialog closed, so an old "Done" sounded new.
- The words come from #23752's announcer, now shared from one place.
- A thread is quiet the first time it's seen. Its environment counts only once its shell is live, so opening the app or reconnecting doesn't read out every thread.
- A snoozed thread stays quiet until its timer, like its sidebar row. A question or a fresh failure ends the snooze, so those still speak.
- The sidebar row label is no longer a live region, so each change is announced once.

Mobile isn't touched; the issue is about the desktop and web client. At phone width the sidebar is a modal sheet, which likely hides this region while the sheet is open; that wasn't tested.

## Evidence

Both recordings run the same script against a dev server with Claude Haiku: start a turn in "Fix the Login Redirect", switch to "Update README documentation", collapse the sidebar and wait for the turn to end; do it again with the sidebar open; then reopen "Fix the Login Redirect". The yellow banner names the build and step.

### Orca 50.2 with Chrome on Linux

Orca runs on a private X screen with its own speech-dispatcher, so the audio is only Orca. The captions are Orca's own speech log, burned in at the time it spoke.

| | Before (#23752 as it was, `627923883b`) | After (this PR) |
| --- | --- | --- |
| Background turn ends, sidebar collapsed | nothing | "Done: Fix the Login Redirect" |
| Background turn ends, sidebar open | nothing | "Done: Fix the Login Redirect" |
| A turn starts in the open thread | nothing | "Working" |
| Reopening the finished thread | nothing | nothing |

Before, the page did write "Working", "Done" and a stale "Done" on reopening; Orca dropped all of it because every region was `role="status"`.

Before:

https://github.com/user-attachments/assets/2fcb53a9-56d0-4937-9f0c-964989bdc85a

After:

https://github.com/user-attachments/assets/669d667e-f3ed-4e8a-b59f-f651c050b4a5

### VoiceOver with Helium on macOS (earlier commit, `94cad94`)

VoiceOver's caption panel, bottom left, shows "Done: Fix the Login Redirect" at 0:21 with the sidebar collapsed and at 1:00 with it open. This commit still used `role="status"`, which VoiceOver reads; the background logic hasn't changed since.

https://github.com/user-attachments/assets/c736a289-e5bd-44e3-a7d5-d11d3ab822a3

The "changed files" cards in the recordings are me switching builds under the running dev server, not the agent.

After an Opus review, the final code was also run in a web dev build on corex with real Codex turns, logging every live-region write: a turn finishing in a background thread wrote "Done: Wait 20 Seconds", and the open-thread cases from #23752 behaved as listed there.

## Tests

`backgroundThreadAnnouncement.test.ts` covers the wording, saying each run's ending once (no replay, a second run caught, rollbacks quiet), a repeat request of the same kind, a queued message cancelled mid-turn, staying quiet on first sight and after a resync, leaving the open thread out, snoozed threads, joining changes that land together, and subagent requests raised on the parent row. `vp run check:changed` passes.

Opus 5.5 in Claude Code

🤖 Generated with [Claude Code](https://claude.com/claude-code)




