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' })