From ee4fd06593a55f53a2a95fbccfd8cffc795cc631 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:17:28 -0700 Subject: [PATCH] fix(mobile): the chat's Working line uses desktop's spinner (#30008) * fix(mobile): the chat's Working line uses desktop's spinner (turning circle, hourglass under reduced motion) * fix(mobile): keep working animations off interaction queue --- mobile/src/components/LoadingSpinner.tsx | 26 +++++++++++++++++++ .../agent-monitoring-indicators.test.ts | 7 +++-- .../components/use-working-ring-rotation.ts | 2 ++ .../MobileNativeChatLiveLine.android.test.ts | 1 + .../session/MobileNativeChatLiveLine.test.ts | 3 ++- .../src/session/MobileNativeChatLiveLine.tsx | 5 ++-- 6 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 mobile/src/components/LoadingSpinner.tsx diff --git a/mobile/src/components/LoadingSpinner.tsx b/mobile/src/components/LoadingSpinner.tsx new file mode 100644 index 00000000000..b213df4b673 --- /dev/null +++ b/mobile/src/components/LoadingSpinner.tsx @@ -0,0 +1,26 @@ +import { Animated } from 'react-native' +import { Hourglass, LoaderCircle } from 'lucide-react-native' +import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useWorkingRingRotation } from './use-working-ring-rotation' + +/** Desktop's LoadingSpinner: the same turning circle, once per second. + * Why: a spinner frozen by reduced motion reads as stuck, so the still state is + * a different icon that says "working" without moving. */ +export function LoadingSpinner({ + size, + color +}: { + size: number + color: string +}): React.JSX.Element { + const reducedMotion = useReducedMotionEnabled() + const rotate = useWorkingRingRotation(reducedMotion === false) + if (reducedMotion) { + return + } + return ( + + + + ) +} diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts index 90b4d3acbe3..1a08cff4a7c 100644 --- a/mobile/src/components/agent-monitoring-indicators.test.ts +++ b/mobile/src/components/agent-monitoring-indicators.test.ts @@ -88,7 +88,7 @@ describe('mobile monitoring indicators', () => { expect(animationLoop).toHaveBeenCalledOnce() }) - it('keeps the native driver for both working rings on native', async () => { + it('keeps both native working rings off the interaction queue', async () => { await act(async () => { renderer = create( createElement('View', null, [ @@ -100,7 +100,10 @@ describe('mobile monitoring indicators', () => { expect(animationTiming).toHaveBeenCalledTimes(2) for (const call of animationTiming.mock.calls) { - expect(call).toEqual([expect.anything(), expect.objectContaining({ useNativeDriver: true })]) + expect(call).toEqual([ + expect.anything(), + expect.objectContaining({ useNativeDriver: true, isInteraction: false }) + ]) } }) diff --git a/mobile/src/components/use-working-ring-rotation.ts b/mobile/src/components/use-working-ring-rotation.ts index b78b223e991..179244f31c1 100644 --- a/mobile/src/components/use-working-ring-rotation.ts +++ b/mobile/src/components/use-working-ring-rotation.ts @@ -12,6 +12,8 @@ export function useWorkingRingRotation(active: boolean): Animated.AnimatedInterp toValue: 1, duration: 1000, easing: Easing.linear, + // Decorative loops must not hold up interaction-scheduled list rendering. + isInteraction: false, // Why: web has no native driver, so a loop over a native-driver timing plays it once. useNativeDriver: Platform.OS !== 'web' }) diff --git a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts index a730da012a4..eb3875e5fab 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts +++ b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts @@ -21,6 +21,7 @@ vi.mock('react-native', async () => { } }) vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) +vi.mock('../components/LoadingSpinner', () => ({ LoadingSpinner: 'LoadingSpinner' })) vi.mock('./MobileNativeChatReasoningRow', () => ({ MobileNativeChatReasoningBody: 'ReasoningBody' })) diff --git a/mobile/src/session/MobileNativeChatLiveLine.test.ts b/mobile/src/session/MobileNativeChatLiveLine.test.ts index a14f89371b4..9034b2b4b37 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.test.ts +++ b/mobile/src/session/MobileNativeChatLiveLine.test.ts @@ -28,6 +28,7 @@ vi.mock('react-native', async () => { } }) vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) +vi.mock('../components/LoadingSpinner', () => ({ LoadingSpinner: 'LoadingSpinner' })) vi.mock('./MobileNativeChatReasoningRow', () => ({ MobileNativeChatReasoningBody: 'ReasoningBody' })) @@ -96,7 +97,7 @@ describe('MobileNativeChatLiveLine', () => { it('reads "Thinking" beside one spinner while the turn reasons', () => { const root = render() expect(labels(root)).toEqual(['Thinking']) - expect(byType(root, 'ActivityIndicator')).toHaveLength(1) + expect(byType(root, 'LoadingSpinner')).toHaveLength(1) }) // The bar owns the clock; the tail line never repeats it. diff --git a/mobile/src/session/MobileNativeChatLiveLine.tsx b/mobile/src/session/MobileNativeChatLiveLine.tsx index 355d3c56c48..b38c65697f8 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.tsx +++ b/mobile/src/session/MobileNativeChatLiveLine.tsx @@ -1,9 +1,10 @@ import { useContext } from 'react' -import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' import { ChevronRight } from 'lucide-react-native' import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' import { formatNativeChatActiveTurnLabel } from '../../../src/shared/native-chat-turn-status' import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection' +import { LoadingSpinner } from '../components/LoadingSpinner' import { colors, spacing, typography } from '../theme/mobile-theme' import { MobileNativeChatReasoningBody } from './MobileNativeChatReasoningRow' import { MobileNativeChatRowOverlaysContext } from './mobile-native-chat-row-overlays' @@ -49,7 +50,7 @@ export function MobileNativeChatLiveLine({ accessibilityLiveRegion="polite" > - + {label}