diff --git a/mobile/src/animation/animated-rotation-loop.test.ts b/mobile/src/animation/animated-rotation-loop.test.ts
new file mode 100644
index 00000000000..2c3bb06f621
--- /dev/null
+++ b/mobile/src/animation/animated-rotation-loop.test.ts
@@ -0,0 +1,92 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Why the NODE_ENV dance: react-native-web swaps its whole Animated implementation for a no-op
+// AnimatedMock whenever NODE_ENV === 'test', so the real web driver is only observable if the
+// module graph is loaded under another env. `platform.os` lets one mock serve both platforms.
+const platform = vi.hoisted(() => ({ os: 'web' as string }))
+
+vi.mock('react-native', async () => {
+ const previous = process.env.NODE_ENV
+ process.env.NODE_ENV = 'production'
+ try {
+ const web = await import('react-native-web')
+ return {
+ Animated: web.Animated,
+ Easing: web.Easing,
+ Platform: {
+ get OS() {
+ return platform.os
+ }
+ }
+ }
+ } finally {
+ process.env.NODE_ENV = previous
+ }
+})
+
+/** Drives Animated's JS driver: it reads Date.now(), so fake timers must move the clock. */
+function installFrameClock(): void {
+ vi.useFakeTimers()
+ Object.assign(globalThis, {
+ requestAnimationFrame: (callback: (timestamp: number) => void) =>
+ setTimeout(() => callback(Date.now()), 16) as unknown as number,
+ cancelAnimationFrame: (handle: number) => clearTimeout(handle as never)
+ })
+}
+
+async function loadRotationLoop() {
+ vi.resetModules()
+ return await import('./animated-rotation-loop')
+}
+
+describe('createRotationLoop', () => {
+ beforeEach(() => {
+ platform.os = 'web'
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('keeps rotating past the first turn on react-native-web', async () => {
+ const { Animated } = await import('react-native')
+ const { createRotationLoop } = await loadRotationLoop()
+ installFrameClock()
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ const value = new Animated.Value(0)
+ const samples: number[] = []
+ value.addListener(({ value: sample }: { value: number }) => samples.push(sample))
+
+ const animation = createRotationLoop(value, 1000)
+ animation.start()
+ vi.advanceTimersByTime(3500)
+ animation.stop()
+
+ // Each restart drops the value back toward 0; a single non-looping pass produces none.
+ const turns = samples.filter((sample, index) => index > 0 && sample < samples[index - 1]).length
+ expect(turns).toBeGreaterThanOrEqual(3)
+ expect(samples.at(-1)).toBeLessThan(1)
+ })
+
+ it('does not claim the missing native driver on web', async () => {
+ const { nativeAnimatedDriverSupported } = await loadRotationLoop()
+ expect(nativeAnimatedDriverSupported).toBe(false)
+ })
+
+ it('still drives the native builds with the native driver', async () => {
+ platform.os = 'android'
+ const reactNative = await import('react-native')
+ const timing = vi.spyOn(reactNative.Animated, 'timing')
+ const { createRotationLoop, nativeAnimatedDriverSupported } = await loadRotationLoop()
+
+ createRotationLoop(new reactNative.Animated.Value(0), 1000)
+
+ expect(nativeAnimatedDriverSupported).toBe(true)
+ expect(timing).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ toValue: 1, duration: 1000, useNativeDriver: true })
+ )
+ })
+})
diff --git a/mobile/src/animation/animated-rotation-loop.ts b/mobile/src/animation/animated-rotation-loop.ts
new file mode 100644
index 00000000000..04e4b9ff0b2
--- /dev/null
+++ b/mobile/src/animation/animated-rotation-loop.ts
@@ -0,0 +1,25 @@
+import { Animated, Easing, Platform } from 'react-native'
+
+// Why: react-native-web ships no native animated module, yet `Animated.loop` still branches on
+// the raw `useNativeDriver` flag rather than on driver availability. With `true` it takes the
+// `_startNativeLoop` path, which on web starts a single JS-driven timing pass with a config the
+// JS driver ignores `iterations` from — the value ramps 0 -> 1 once and then stops forever, so
+// every looping spinner freezes in the hybrid WebView. Only claim the driver where it exists.
+export const nativeAnimatedDriverSupported = Platform.OS !== 'web'
+
+const SPIN_DURATION_MS = 1000
+
+/** Continuous 0 -> 1 rotation driver shared by the worktree and per-agent busy spinners. */
+export function createRotationLoop(
+ value: Animated.Value,
+ durationMs: number = SPIN_DURATION_MS
+): Animated.CompositeAnimation {
+ return Animated.loop(
+ Animated.timing(value, {
+ toValue: 1,
+ duration: durationMs,
+ easing: Easing.linear,
+ useNativeDriver: nativeAnimatedDriverSupported
+ })
+ )
+}
diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx
index c05e5d2a489..d24549b1362 100644
--- a/mobile/src/components/AgentSpinner.tsx
+++ b/mobile/src/components/AgentSpinner.tsx
@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react'
import { Activity } from 'lucide-react-native'
-import { Animated, Easing, StyleSheet, View } from 'react-native'
+import { Animated, StyleSheet, View } from 'react-native'
+import { createRotationLoop } from '../animation/animated-rotation-loop'
+import { busyRingColors } from './agent-busy-ring'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
@@ -31,14 +33,7 @@ export function AgentSpinner({
useEffect(() => {
if (status === 'working' && !monitoring) {
- const animation = Animated.loop(
- Animated.timing(spinValue, {
- toValue: 1,
- duration: 1000,
- easing: Easing.linear,
- useNativeDriver: true
- })
- )
+ const animation = createRotationLoop(spinValue)
animation.start()
return () => animation.stop()
}
@@ -62,7 +57,9 @@ export function AgentSpinner({
})
return (
-
+
)
}
@@ -94,7 +91,6 @@ const styles = StyleSheet.create({
width: 8,
height: 8,
borderRadius: 4,
- borderWidth: 2,
- borderTopColor: 'transparent'
+ borderWidth: 2
}
})
diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx
index 1637296d900..53a68574fde 100644
--- a/mobile/src/components/AgentStateDot.tsx
+++ b/mobile/src/components/AgentStateDot.tsx
@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react'
import { Activity } from 'lucide-react-native'
-import { Animated, Easing, StyleSheet, View } from 'react-native'
+import { Animated, StyleSheet, View } from 'react-native'
+import { createRotationLoop } from '../animation/animated-rotation-loop'
+import { busyRingColors } from './agent-busy-ring'
import type { AgentDotState } from '../worktree/agent-row-display'
// Per-agent state indicator, 1:1 with desktop AgentStateDot
@@ -22,14 +24,7 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
useEffect(() => {
if (state === 'working') {
- const animation = Animated.loop(
- Animated.timing(spinValue, {
- toValue: 1,
- duration: 1000,
- easing: Easing.linear,
- useNativeDriver: true
- })
- )
+ const animation = createRotationLoop(spinValue)
animation.start()
return () => animation.stop()
}
@@ -41,7 +36,9 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
const rotate = spinValue.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] })
return (
-
+
)
}
@@ -68,8 +65,6 @@ const styles = StyleSheet.create({
width: 6,
height: 6,
borderRadius: 3,
- borderWidth: 1.5,
- borderColor: WORKING_COLOR,
- borderTopColor: 'transparent'
+ borderWidth: 1.5
}
})
diff --git a/mobile/src/components/agent-busy-ring.test.tsx b/mobile/src/components/agent-busy-ring.test.tsx
new file mode 100644
index 00000000000..77713433b81
--- /dev/null
+++ b/mobile/src/components/agent-busy-ring.test.tsx
@@ -0,0 +1,38 @@
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+import { AgentSpinner } from './AgentSpinner'
+import { AgentStateDot } from './AgentStateDot'
+import { busyRingColors } from './agent-busy-ring'
+
+// Renders the real components through react-native-web, which is what the hybrid WebView loads.
+vi.mock('react-native', async () => await import('react-native-web'))
+vi.mock('lucide-react-native', () => ({ Activity: () => null }))
+
+const TRANSPARENT = 'rgba(0,0,0,0.00)'
+
+function inlineStyle(markup: string): string {
+ return markup.match(/style="([^"]*)"/)?.[1] ?? ''
+}
+
+describe('busy spinner ring on react-native-web', () => {
+ it('puts the transparent gap after the ring color', () => {
+ expect(Object.keys(busyRingColors('#eab308'))).toEqual(['borderColor', 'borderTopColor'])
+ })
+
+ it('leaves the worktree spinner top edge transparent', () => {
+ const style = inlineStyle(
+ renderToStaticMarkup(createElement(AgentSpinner, { status: 'working' }))
+ )
+ expect(style).toContain(`border-top-color:${TRANSPARENT}`)
+ expect(style).toContain('border-right-color:rgba(234,179,8,1.00)')
+ })
+
+ it('leaves the agent state dot top edge transparent', () => {
+ const style = inlineStyle(
+ renderToStaticMarkup(createElement(AgentStateDot, { state: 'working' }))
+ )
+ expect(style).toContain(`border-top-color:${TRANSPARENT}`)
+ expect(style).toContain('border-right-color:rgba(234,179,8,1.00)')
+ })
+})
diff --git a/mobile/src/components/agent-busy-ring.ts b/mobile/src/components/agent-busy-ring.ts
new file mode 100644
index 00000000000..a66dd3c0592
--- /dev/null
+++ b/mobile/src/components/agent-busy-ring.ts
@@ -0,0 +1,9 @@
+/** Ring colors for the busy spinner: a full-color border with the top edge cut out.
+ *
+ * Why a helper: on react-native-web `borderColor` is emitted as four inline longhands, so a
+ * later `borderColor` in the style array silently overwrites a `borderTopColor: 'transparent'`
+ * set by an earlier StyleSheet rule and the spinner renders as a solid ring. Keeping both keys
+ * in one object, gap last, is the only ordering that survives on web and on native. */
+export function busyRingColors(color: string): { borderColor: string; borderTopColor: string } {
+ return { borderColor: color, borderTopColor: 'transparent' }
+}
diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts
index eefa2137f14..7eb5a3bb740 100644
--- a/mobile/src/components/agent-monitoring-indicators.test.ts
+++ b/mobile/src/components/agent-monitoring-indicators.test.ts
@@ -30,6 +30,7 @@ vi.mock('react-native', () => ({
timing: animationTiming
},
Easing: { linear: 'linear' },
+ Platform: { OS: 'ios' },
StyleSheet: { create: (styles: T) => styles },
View: 'View'
}))