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}