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:
Brennan Benson
2026-08-04 01:54:18 -07:00
parent e9cf106769
commit e9e736d766
5 changed files with 240 additions and 18 deletions
@@ -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)
})
})
+11 -2
View File
@@ -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,