diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index bb1b5b68413..5f2284b1e21 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -228,6 +228,12 @@ describe('MobileNativeChatView', () => { }) } + async function settleStatusStale(): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + } + async function relock(inputLockReason: Overrides['inputLockReason']): Promise { 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) }) diff --git a/mobile/src/session/use-mobile-native-chat-control-state.ts b/mobile/src/session/use-mobile-native-chat-control-state.ts index 1f92a352a7d..650d49cd40b 100644 --- a/mobile/src/session/use-mobile-native-chat-control-state.ts +++ b/mobile/src/session/use-mobile-native-chat-control-state.ts @@ -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,