mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
fix(mobile): mute stale agent status and disable Stop while disconnected
The working row kept animating "Agent is working" and Stop stayed live after the transport dropped, so pre-disconnect status read as live activity and a Stop tap could not reach the PTY. Derive staleness from the debounced composer lock so connection blips don't flicker the row. Refs STA-3333.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn() }))
|
||||
|
||||
vi.mock('react-native', () => {
|
||||
const animation = { start: mocks.start, stop: mocks.stop }
|
||||
return {
|
||||
Animated: {
|
||||
Value: class {},
|
||||
View: 'AnimatedView',
|
||||
delay: vi.fn(() => animation),
|
||||
loop: vi.fn(() => animation),
|
||||
sequence: vi.fn(() => animation),
|
||||
timing: vi.fn(() => animation)
|
||||
},
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}
|
||||
})
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
describe('MobileAgentWorkingIndicator', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
mocks.start.mockClear()
|
||||
mocks.stop.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
async function render(stale?: boolean): Promise<void> {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(MobileAgentWorkingIndicator, { stale }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
function label(): string {
|
||||
return renderer!.root
|
||||
.findAll((node) => node.type === 'Text')
|
||||
.map((node) => node.props.children)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function dots(): number {
|
||||
return renderer!.root.findAll((node) => node.type === 'AnimatedView').length
|
||||
}
|
||||
|
||||
it('animates a live working row', async () => {
|
||||
await render(false)
|
||||
|
||||
expect(label()).toBe('Agent is working')
|
||||
expect(dots()).toBe(3)
|
||||
expect(mocks.start).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('mutes the row and stops the animation while the status is stale', async () => {
|
||||
await render(true)
|
||||
|
||||
expect(label()).toBe('Agent status stale')
|
||||
// Dots are the "live activity" signal — they must not survive a disconnect.
|
||||
expect(dots()).toBe(0)
|
||||
expect(mocks.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops the running animation when a live row goes stale', async () => {
|
||||
await render(false)
|
||||
mocks.start.mockClear()
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(createElement(MobileAgentWorkingIndicator, { stale: true }))
|
||||
})
|
||||
|
||||
expect(mocks.stop).toHaveBeenCalledTimes(3)
|
||||
expect(mocks.start).not.toHaveBeenCalled()
|
||||
expect(label()).toBe('Agent status stale')
|
||||
})
|
||||
|
||||
it('resumes the animation when the transport recovers', async () => {
|
||||
await render(true)
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(createElement(MobileAgentWorkingIndicator, { stale: false }))
|
||||
})
|
||||
|
||||
expect(mocks.start).toHaveBeenCalledTimes(3)
|
||||
expect(label()).toBe('Agent is working')
|
||||
expect(dots()).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -3,37 +3,51 @@ import { Animated, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
/** Animated three-dot "agent is working" row, shown while the active agent is
|
||||
* still producing a reply. Pure presentation — visibility is the caller's call. */
|
||||
export function MobileAgentWorkingIndicator(): React.JSX.Element {
|
||||
* still producing a reply. Pure presentation — visibility is the caller's call.
|
||||
* `stale` mutes it while the transport is down: the state came from
|
||||
* pre-disconnect data and must not read as live activity. */
|
||||
export function MobileAgentWorkingIndicator({
|
||||
stale = false
|
||||
}: {
|
||||
stale?: boolean
|
||||
}): React.JSX.Element {
|
||||
const firstDot = useRef(new Animated.Value(0.3)).current
|
||||
const secondDot = useRef(new Animated.Value(0.3)).current
|
||||
const thirdDot = useRef(new Animated.Value(0.3)).current
|
||||
const dots = [
|
||||
useRef(new Animated.Value(0.3)).current,
|
||||
useRef(new Animated.Value(0.3)).current,
|
||||
useRef(new Animated.Value(0.3)).current
|
||||
{ id: 'first', opacity: firstDot },
|
||||
{ id: 'second', opacity: secondDot },
|
||||
{ id: 'third', opacity: thirdDot }
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
const animations = dots.map((dot, i) =>
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(i * 160),
|
||||
Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true }),
|
||||
Animated.timing(dot, { toValue: 0.3, duration: 320, useNativeDriver: true })
|
||||
Animated.timing(dot.opacity, { toValue: 1, duration: 320, useNativeDriver: true }),
|
||||
Animated.timing(dot.opacity, { toValue: 0.3, duration: 320, useNativeDriver: true })
|
||||
])
|
||||
)
|
||||
)
|
||||
animations.forEach((a) => a.start())
|
||||
return () => animations.forEach((a) => a.stop())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [stale])
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Agent is working</Text>
|
||||
<View style={styles.dots}>
|
||||
{dots.map((dot, i) => (
|
||||
<Animated.View key={i} style={[styles.dot, { opacity: dot }]} />
|
||||
))}
|
||||
</View>
|
||||
<View style={[styles.row, stale && styles.rowStale]}>
|
||||
<Text style={styles.label}>{stale ? 'Agent status stale' : 'Agent is working'}</Text>
|
||||
{stale ? null : (
|
||||
<View style={styles.dots}>
|
||||
{dots.map((dot) => (
|
||||
<Animated.View key={dot.id} style={[styles.dot, { opacity: dot.opacity }]} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -46,6 +60,9 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
rowStale: {
|
||||
opacity: 0.55
|
||||
},
|
||||
label: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileNativeChatView } from './MobileNativeChatView'
|
||||
import { styles } from './mobile-native-chat-view-styles'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
@@ -61,6 +62,8 @@ type Overrides = {
|
||||
sendErrorMessage?: string | null
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
agentWorking?: boolean
|
||||
onStop?: () => void
|
||||
onSend?: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -75,7 +78,7 @@ function suppressRendererWarning(): () => void {
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
describe('MobileNativeChatView send-error banner', () => {
|
||||
describe('MobileNativeChatView', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -85,6 +88,7 @@ describe('MobileNativeChatView send-error banner', () => {
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function render(overrides: Overrides = {}): Promise<void> {
|
||||
@@ -161,4 +165,80 @@ describe('MobileNativeChatView send-error banner', () => {
|
||||
|
||||
expect(onClearSendError).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
function workingIndicator(): ReactTestInstance {
|
||||
return renderer!.root.find((node) => node.type === 'WorkingIndicator')
|
||||
}
|
||||
|
||||
function stopButton(): ReactTestInstance {
|
||||
return renderer!.root.find((node) => node.props.accessibilityLabel === 'Stop the agent')
|
||||
}
|
||||
|
||||
async function settleLockDebounce(): Promise<void> {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
})
|
||||
}
|
||||
|
||||
it('marks the working row stale and blocks Stop once a disconnect settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
await render({ agentWorking: true, inputLockReason: 'disconnected' })
|
||||
|
||||
// Debounced with the composer lock: a blip must not flicker the row.
|
||||
expect(workingIndicator().props.stale).toBe(false)
|
||||
expect(stopButton().props.disabled).toBe(false)
|
||||
|
||||
await settleLockDebounce()
|
||||
|
||||
expect(workingIndicator().props.stale).toBe(true)
|
||||
expect(stopButton().props.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not fire Stop while the status is stale', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onStop = vi.fn()
|
||||
await render({ agentWorking: true, inputLockReason: 'disconnected', onStop })
|
||||
await settleLockDebounce()
|
||||
|
||||
const stop = stopButton()
|
||||
expect(stop.props.disabled).toBe(true)
|
||||
// Pressable swallows the press itself; assert the handler stays unreachable.
|
||||
expect(stop.props.style({ pressed: false })).toContainEqual(styles.stopDisabled)
|
||||
expect(onStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the row live when the agent is merely waiting on a lease', async () => {
|
||||
vi.useFakeTimers()
|
||||
await render({ agentWorking: true, inputLockReason: 'waiting' })
|
||||
await settleLockDebounce()
|
||||
|
||||
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' })
|
||||
await settleLockDebounce()
|
||||
expect(stopButton().props.disabled).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(MobileNativeChatView, {
|
||||
messages: [],
|
||||
status: 'ready',
|
||||
agentWorking: true,
|
||||
inputLockReason: null,
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
pending: [],
|
||||
composerText: '',
|
||||
onComposerTextChange: vi.fn()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Unlock is immediate (no debounce on the way out), so Stop comes straight back.
|
||||
expect(workingIndicator().props.stale).toBe(false)
|
||||
expect(stopButton().props.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -256,6 +256,10 @@ export function MobileNativeChatView({
|
||||
return () => clearTimeout(timer)
|
||||
}, [rawLockReason])
|
||||
const lockReason = lockHeld ? rawLockReason : null
|
||||
// Everything derived from agent status is pre-disconnect data while the
|
||||
// transport is down: mute the working row and disable Stop (a tap could not
|
||||
// reach the PTY anyway). Shares the lock's debounce so blips don't flicker it.
|
||||
const statusStale = lockReason === 'disconnected'
|
||||
|
||||
return (
|
||||
<View style={[styles.root, { paddingBottom: bottomPad }]}>
|
||||
@@ -370,7 +374,7 @@ export function MobileNativeChatView({
|
||||
tool-calls expand/collapse toggle on the left, Stop in the far corner. */}
|
||||
<View style={styles.chromeRow}>
|
||||
<View style={styles.chromeLeft}>
|
||||
{agentWorking ? <MobileAgentWorkingIndicator /> : null}
|
||||
{agentWorking ? <MobileAgentWorkingIndicator stale={statusStale} /> : null}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.chromeToggle, pressed && styles.pressed]}
|
||||
onPress={() => setToolsExpanded((v) => !v)}
|
||||
@@ -386,8 +390,13 @@ export function MobileNativeChatView({
|
||||
</View>
|
||||
{agentWorking ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.stopButton, pressed && styles.pressed]}
|
||||
style={({ pressed }) => [
|
||||
styles.stopButton,
|
||||
pressed && styles.pressed,
|
||||
statusStale && styles.stopDisabled
|
||||
]}
|
||||
onPress={onStop}
|
||||
disabled={statusStale}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Stop the agent"
|
||||
>
|
||||
|
||||
@@ -24,6 +24,9 @@ export const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs
|
||||
},
|
||||
stopDisabled: {
|
||||
opacity: 0.4
|
||||
},
|
||||
stopLabel: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.metaSize,
|
||||
|
||||
Reference in New Issue
Block a user