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
This commit is contained in:
Brennan Benson
2026-10-17 01:17:28 -07:00
committed by GitHub
parent a5b96a1f8d
commit ee4fd06593
6 changed files with 39 additions and 5 deletions
+26
View File
@@ -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 <Hourglass size={size} color={color} strokeWidth={2} />
}
return (
<Animated.View style={{ transform: [{ rotate }] }}>
<LoaderCircle size={size} color={color} strokeWidth={2} />
</Animated.View>
)
}
@@ -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 })
])
}
})
@@ -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'
})
@@ -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'
}))
@@ -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.
@@ -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"
>
<View style={styles.glyph}>
<ActivityIndicator size="small" color={colors.textMuted} />
<LoadingSpinner size={15} color={colors.textMuted} />
</View>
<Text style={styles.label} numberOfLines={1}>
{label}