diff --git a/mobile/src/session/MobileAgentWorkingIndicator.test.ts b/mobile/src/session/MobileAgentWorkingIndicator.test.ts new file mode 100644 index 00000000000..3b28bd159f9 --- /dev/null +++ b/mobile/src/session/MobileAgentWorkingIndicator.test.ts @@ -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 { + 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) + }) +}) diff --git a/mobile/src/session/MobileAgentWorkingIndicator.tsx b/mobile/src/session/MobileAgentWorkingIndicator.tsx index cb766b0be51..fc66c2525b8 100644 --- a/mobile/src/session/MobileAgentWorkingIndicator.tsx +++ b/mobile/src/session/MobileAgentWorkingIndicator.tsx @@ -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 ( - - Agent is working - - {dots.map((dot, i) => ( - - ))} - + + {stale ? 'Agent status stale' : 'Agent is working'} + {stale ? null : ( + + {dots.map((dot) => ( + + ))} + + )} ) } @@ -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, diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 86ead424b90..0c2a5623f36 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -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 } @@ -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 { @@ -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 { + 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) + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index f14efc1f25f..ff9adaf7f85 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -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 ( @@ -370,7 +374,7 @@ export function MobileNativeChatView({ tool-calls expand/collapse toggle on the left, Stop in the far corner. */} - {agentWorking ? : null} + {agentWorking ? : null} [styles.chromeToggle, pressed && styles.pressed]} onPress={() => setToolsExpanded((v) => !v)} @@ -386,8 +390,13 @@ export function MobileNativeChatView({ {agentWorking ? ( [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" > diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts index 160477cfad8..7c2be4301d8 100644 --- a/mobile/src/session/mobile-native-chat-view-styles.ts +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -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,