fix(mobile): avoid transient stale status flashes

This commit is contained in:
Brennan Benson
2026-08-05 20:53:25 -07:00
parent c8dd8463af
commit b760b73c9b
2 changed files with 34 additions and 17 deletions
+27 -11
View File
@@ -228,6 +228,12 @@ describe('MobileNativeChatView', () => {
})
}
async function settleStatusStale(): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000)
})
}
async function relock(inputLockReason: Overrides['inputLockReason']): Promise<void> {
await update({
agentWorking: true,
@@ -245,7 +251,18 @@ describe('MobileNativeChatView', () => {
expect(workingIndicator().props.stale).toBe(false)
expect(stopButton().props.disabled).toBe(true)
await settleLockDebounce()
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
await update({ agentWorking: true, inputLockReason: 'disconnected', onStop: vi.fn() })
await act(async () => {
await vi.advanceTimersByTimeAsync(999)
})
expect(workingIndicator().props.stale).toBe(false)
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(workingIndicator().props.stale).toBe(true)
expect(stopButton().props.disabled).toBe(true)
@@ -264,7 +281,7 @@ describe('MobileNativeChatView', () => {
expect(workingIndicator().props.stale).toBe(false)
expect(stopButton().props.disabled).toBe(true)
await settleLockDebounce()
await settleStatusStale()
expect(workingIndicator().props.stale).toBe(true)
expect(stopButton().props.disabled).toBe(true)
@@ -274,7 +291,6 @@ describe('MobileNativeChatView', () => {
vi.useFakeTimers()
const onStop = vi.fn()
await render({ agentWorking: true, inputLockReason: 'disconnected', onStop })
await settleLockDebounce()
const stop = stopButton()
// `disabled` is the behavioral guard; opacity is only its affordance.
@@ -293,7 +309,7 @@ describe('MobileNativeChatView', () => {
})
// Status liveness owns a separate hold from the composer's lock feedback.
it('still holds 600ms when a settled lease wait drops to disconnected', async () => {
it('requires two seconds of continuous loss when a settled lease wait disconnects', async () => {
vi.useFakeTimers()
await render({ agentWorking: true, inputLockReason: 'waiting' })
await settleLockDebounce()
@@ -303,7 +319,7 @@ describe('MobileNativeChatView', () => {
expect(workingIndicator().props.stale).toBe(false)
expect(stopButton().props.disabled).toBe(true)
await settleLockDebounce()
await settleStatusStale()
expect(workingIndicator().props.stale).toBe(true)
expect(stopButton().props.disabled).toBe(true)
@@ -314,10 +330,10 @@ describe('MobileNativeChatView', () => {
await render({ agentWorking: true, inputLockReason: 'disconnected' })
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(1_999)
})
await relock('waiting')
await settleLockDebounce()
await settleStatusStale()
// A surviving timer would mute a row whose transport is already back.
expect(workingIndicator().props.stale).toBe(false)
@@ -327,7 +343,7 @@ describe('MobileNativeChatView', () => {
it('clears stale feedback on reconnect and restores Stop once the lease is ready', async () => {
vi.useFakeTimers()
await render({ agentWorking: true, inputLockReason: 'disconnected' })
await settleLockDebounce()
await settleStatusStale()
expect(stopButton().props.disabled).toBe(true)
// Reconnect always lands on 'waiting' first — the lease drops its ready
@@ -347,7 +363,7 @@ describe('MobileNativeChatView', () => {
it('never renders stale feedback after the transport reconnects', async () => {
vi.useFakeTimers()
await render({ agentWorking: true, inputLockReason: 'disconnected' })
await settleLockDebounce()
await settleStatusStale()
expect(workingIndicator().props.stale).toBe(true)
mocks.staleRenders.length = 0
@@ -360,13 +376,13 @@ describe('MobileNativeChatView', () => {
it('restarts the hold when the recovered transport disconnects again', async () => {
vi.useFakeTimers()
await render({ agentWorking: true, inputLockReason: 'disconnected' })
await settleLockDebounce()
await settleStatusStale()
await relock('waiting')
await relock('disconnected')
expect(workingIndicator().props.stale).toBe(false)
await settleLockDebounce()
await settleStatusStale()
expect(workingIndicator().props.stale).toBe(true)
})
@@ -1,20 +1,21 @@
import { useEffect, useState } from 'react'
import { canStopNativeChatAgent } from '../../../src/shared/native-chat-action-availability'
const CONTROL_STATE_SETTLE_MS = 600
const COMPOSER_LOCK_SETTLE_MS = 600
const AGENT_STATUS_STALE_AFTER_MS = 2_000
export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting'
function useDelayedActivation(active: boolean): boolean {
function useDelayedActivation(active: boolean, delayMs: number): boolean {
const [delayedActive, setDelayedActive] = useState(false)
useEffect(() => {
if (!active) {
setDelayedActive(false)
return
}
const timer = setTimeout(() => setDelayedActive(true), CONTROL_STATE_SETTLE_MS)
const timer = setTimeout(() => setDelayedActive(true), delayMs)
return () => clearTimeout(timer)
}, [active])
}, [active, delayMs])
return active && delayedActive
}
@@ -34,10 +35,10 @@ export function useMobileNativeChatControlState(args: {
if (rawLockHeld === lockHeld) {
return
}
const timer = setTimeout(() => setLockHeld(rawLockHeld), CONTROL_STATE_SETTLE_MS)
const timer = setTimeout(() => setLockHeld(rawLockHeld), COMPOSER_LOCK_SETTLE_MS)
return () => clearTimeout(timer)
}, [lockHeld, rawLockHeld])
const statusStale = useDelayedActivation(!args.agentStatusLive)
const statusStale = useDelayedActivation(!args.agentStatusLive, AGENT_STATUS_STALE_AFTER_MS)
return {
lockReason: lockHeld ? (args.inputLockReason ?? 'waiting') : null,