mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
fix(mobile): avoid transient stale status flashes
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user