fix(mobile): announce the stale-status swap and pin the hold's cancel path

The row rewrites itself from "Agent is working" to "Agent status stale"
with no live region, so screen-reader users got no cue for the exact
state this change exists to communicate (WCAG 2.1 SC 4.1.3). Matches the
send-error banner's existing pattern in this view.

Also covers the anti-flicker guarantee itself: a link that recovers
inside the 600ms hold must cancel the armed timer, not merely lose the
race with it.
This commit is contained in:
Brennan Benson
2026-08-04 02:39:05 -07:00
parent 585b24ba5a
commit fb50454d4d
2 changed files with 19 additions and 1 deletions
@@ -41,7 +41,9 @@ export function MobileAgentWorkingIndicator({
return (
// Not dimmed: the label is the only thing left carrying the state once the
// dots go, and fading it under `textMuted` drops it below AA on `bgBase`.
<View style={styles.row}>
// The live region is constant, not toggled with `stale` — Android only
// announces content changes once the mode is already set.
<View style={styles.row} accessibilityLiveRegion="polite">
<Text style={styles.label}>{stale ? 'Agent status stale' : 'Agent is working'}</Text>
{stale ? null : (
<View style={styles.dots}>
@@ -252,6 +252,22 @@ describe('MobileNativeChatView', () => {
expect(stopButton().props.disabled).toBe(true)
})
it('never goes stale when the link recovers inside the hold', async () => {
vi.useFakeTimers()
await render({ agentWorking: true, inputLockReason: 'disconnected' })
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
await relock('waiting')
await settleLockDebounce()
// The armed timer has to be cancelled, not just overruled — a surviving one
// would fire late and mute a row whose transport is already back.
expect(workingIndicator().props.stale).toBe(false)
expect(stopButton().props.disabled).toBe(false)
})
it('restores a live Stop as soon as the transport reconnects', async () => {
vi.useFakeTimers()
await render({ agentWorking: true, inputLockReason: 'disconnected' })