From fb50454d4d8b35db571aa84e56a3b9af220d200b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:39:05 -0700 Subject: [PATCH] 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. --- .../src/session/MobileAgentWorkingIndicator.tsx | 4 +++- mobile/src/session/MobileNativeChatView.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/mobile/src/session/MobileAgentWorkingIndicator.tsx b/mobile/src/session/MobileAgentWorkingIndicator.tsx index d2e6cc12763..24096a2696b 100644 --- a/mobile/src/session/MobileAgentWorkingIndicator.tsx +++ b/mobile/src/session/MobileAgentWorkingIndicator.tsx @@ -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`. - + // The live region is constant, not toggled with `stale` — Android only + // announces content changes once the mode is already set. + {stale ? 'Agent status stale' : 'Agent is working'} {stale ? null : ( diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index dd5401b4898..299d62f74de 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -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' })