From 1978469fd21804fd4414ad34b3255edf6aaa825f Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Sun, 4 Oct 2026 19:12:00 -0400
Subject: [PATCH] fix(mobile): keep the working rings turning on the OTA page
(#25299)
* fix(mobile): keep the working rings turning on the OTA page
Animated.loop starts a native loop whenever the timing asks for the native
driver; the web has none, so the JS fallback ran one turn and froze at 360deg.
Ask for the native driver only off the web.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the native driver on native spinners
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): share the working ring rotation between both rings
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
---
...le-web-app-working-spinner-render.test.mjs | 145 ++++++++++++++++++
mobile/src/components/AgentSpinner.tsx | 26 +---
mobile/src/components/AgentStateDot.tsx | 24 +--
.../agent-monitoring-indicators.test.ts | 17 ++
.../components/use-working-ring-rotation.ts | 27 ++++
5 files changed, 195 insertions(+), 44 deletions(-)
create mode 100644 config/scripts/mobile-web-app-working-spinner-render.test.mjs
create mode 100644 mobile/src/components/use-working-ring-rotation.ts
diff --git a/config/scripts/mobile-web-app-working-spinner-render.test.mjs b/config/scripts/mobile-web-app-working-spinner-render.test.mjs
new file mode 100644
index 00000000000..3ac7cdd2e37
--- /dev/null
+++ b/config/scripts/mobile-web-app-working-spinner-render.test.mjs
@@ -0,0 +1,145 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { chromium } from 'playwright-core'
+import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
+import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
+import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs'
+import {
+ createBundleServer,
+ installShellDouble,
+ projectDir,
+ readBridgeFaultGrant,
+ readBridgeProtocolVersion,
+ readShellCsp
+} from './mobile-web-app-render-harness.mjs'
+import { LAYOUT_SOURCE } from './mobile-web-app-terminal-probe-route.mjs'
+
+/**
+ * The working rings keep turning on the page. Each is a one-second rotation loop; a loop that
+ * stops after its first turn reads `rotate(360deg)` from then on.
+ */
+
+const ROUTE = `/${MOBILE_WEB_APP_ROUTE_ROOT}/working-spinner-probe`
+const SAMPLE_SECONDS = [1.3, 1.8, 2.4]
+const bundles = mobileWebAppDependenciesPresent()
+const describeRender = bundles ? describe : describe.skip
+
+function probeRouteSource({ spinnerModule, dotModule }) {
+ return `import { View } from 'react-native'
+import { AgentSpinner } from ${JSON.stringify(spinnerModule)}
+import { AgentStateDot } from ${JSON.stringify(dotModule)}
+
+export default function WorkingSpinnerProbeRoute() {
+ globalThis.__orcaWorkingSpinnerProbe = true
+ return (
+
+
+
+
+ )
+}
+`
+}
+
+let browser = null
+let origin = null
+let scratch = null
+let server = null
+
+beforeAll(async () => {
+ if (!bundles) {
+ return
+ }
+ const componentsDir = join(projectDir, 'mobile', 'src', 'components')
+ const cspHeader = await readShellCsp()
+ scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-working-spinner-'))
+ const appDir = join(scratch, 'app')
+ const routeDir = join(appDir, MOBILE_WEB_APP_ROUTE_ROOT)
+ await mkdir(routeDir, { recursive: true })
+ await writeFile(join(routeDir, '_layout.tsx'), LAYOUT_SOURCE)
+ await writeFile(
+ join(routeDir, 'working-spinner-probe.tsx'),
+ probeRouteSource({
+ spinnerModule: join(componentsDir, 'AgentSpinner'),
+ dotModule: join(componentsDir, 'AgentStateDot')
+ })
+ )
+ const built = await buildMobileWebAppBundle({
+ appDir,
+ outDir: join(scratch, 'bundle'),
+ pageRoutes: [{ pathname: ROUTE, grants: [] }]
+ })
+ const served = await createBundleServer({ outDir: built.outDir, cspHeader })
+ server = served.server
+ origin = served.origin
+ const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
+ browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
+}, 600_000)
+
+afterAll(async () => {
+ await browser?.close()
+ server?.close()
+ if (scratch) {
+ await rm(scratch, { recursive: true, force: true })
+ }
+})
+
+async function openProbe() {
+ const faultGrant = await readBridgeFaultGrant()
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
+ await page.addInitScript(installShellDouble, {
+ version: await readBridgeProtocolVersion(),
+ sessionId: 'working-spinner-session',
+ buildId: 'working-spinner-build',
+ route: { pathname: ROUTE, params: {} },
+ host: { id: 'spinner-host', name: 'Spinner Host', endpoint: 'ws://spinner', lastConnected: 1 },
+ storage: {},
+ faultGrant,
+ grants: [faultGrant],
+ pageRoutes: [ROUTE],
+ replies: {}
+ })
+ const errors = []
+ page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
+ await page.goto(`${origin}/`, { waitUntil: 'load' })
+ await page.waitForFunction(
+ () =>
+ globalThis.__orcaWorkingSpinnerProbe !== undefined ||
+ (globalThis.__orcaRenderCheckFaults ?? []).length > 0,
+ { timeout: 60_000, polling: 100 }
+ )
+ expect(await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])).toEqual([])
+ return { errors, page }
+}
+
+/** The rotating ring is the only element under each wrapper that carries an inline transform. */
+const readRotations = (page) =>
+ page.evaluate(() =>
+ ['working-spinner', 'working-dot'].map(
+ (id) =>
+ [...document.querySelectorAll(`#${id} *`)].find((node) => node.style.transform)?.style
+ .transform ?? null
+ )
+ )
+
+describeRender('working rings on the page', () => {
+ it('keeps both rings turning past the first second', async () => {
+ const { errors, page } = await openProbe()
+ const started = Date.now()
+ const samples = []
+ for (const seconds of SAMPLE_SECONDS) {
+ await page.waitForTimeout(Math.max(0, started + seconds * 1000 - Date.now()))
+ samples.push(await readRotations(page))
+ }
+ for (const ring of [0, 1]) {
+ const angles = samples.map((sample) => sample[ring])
+ expect(new Set(angles).size, `ring ${ring} after 1.3 s: ${angles.join(', ')}`).toBe(
+ angles.length
+ )
+ }
+ expect(errors).toEqual([])
+ await page.close()
+ }, 300_000)
+})
diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx
index c05e5d2a489..38e4674d53d 100644
--- a/mobile/src/components/AgentSpinner.tsx
+++ b/mobile/src/components/AgentSpinner.tsx
@@ -1,7 +1,7 @@
-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 type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
+import { useWorkingRingRotation } from './use-working-ring-rotation'
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
@@ -26,24 +26,8 @@ export function AgentSpinner({
status: WorktreeStatus
workingMode?: AgentWorkingMode
}) {
- const spinValue = useRef(new Animated.Value(0)).current
const monitoring = status === 'working' && workingMode === 'monitoring'
-
- useEffect(() => {
- if (status === 'working' && !monitoring) {
- const animation = Animated.loop(
- Animated.timing(spinValue, {
- toValue: 1,
- duration: 1000,
- easing: Easing.linear,
- useNativeDriver: true
- })
- )
- animation.start()
- return () => animation.stop()
- }
- spinValue.setValue(0)
- }, [monitoring, status, spinValue])
+ const rotate = useWorkingRingRotation(status === 'working' && !monitoring)
const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive
@@ -56,10 +40,6 @@ export function AgentSpinner({
}
if (status === 'working') {
- const rotate = spinValue.interpolate({
- inputRange: [0, 1],
- outputRange: ['0deg', '360deg']
- })
return (
diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx
index 6912f6c1b2a..0450afa8166 100644
--- a/mobile/src/components/AgentStateDot.tsx
+++ b/mobile/src/components/AgentStateDot.tsx
@@ -1,8 +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 type { AgentDotState } from '../worktree/agent-row-display'
import { colors } from '../theme/mobile-theme'
+import { useWorkingRingRotation } from './use-working-ring-rotation'
// Per-agent state indicator, 1:1 with desktop AgentStateDot
// (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working',
@@ -22,27 +22,9 @@ const DOT_COLORS: Record, strin
const WORKING_COLOR = '#eab308'
export function AgentStateDot({ state }: { state: AgentDotState }) {
- const spinValue = useRef(new Animated.Value(0)).current
-
- useEffect(() => {
- if (state === 'working') {
- const animation = Animated.loop(
- Animated.timing(spinValue, {
- toValue: 1,
- duration: 1000,
- easing: Easing.linear,
- useNativeDriver: true
- })
- )
- animation.start()
- return () => animation.stop()
- }
- spinValue.setValue(0)
- return undefined
- }, [state, spinValue])
+ const rotate = useWorkingRingRotation(state === 'working')
if (state === 'working') {
- const rotate = spinValue.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] })
return (
diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts
index 16c453b27cf..90b4d3acbe3 100644
--- a/mobile/src/components/agent-monitoring-indicators.test.ts
+++ b/mobile/src/components/agent-monitoring-indicators.test.ts
@@ -32,6 +32,7 @@ vi.mock('react-native', () => ({
timing: animationTiming
},
Easing: { linear: 'linear' },
+ Platform: { OS: 'ios' },
StyleSheet: { create: (styles: T) => styles },
View: 'View'
}))
@@ -87,6 +88,22 @@ describe('mobile monitoring indicators', () => {
expect(animationLoop).toHaveBeenCalledOnce()
})
+ it('keeps the native driver for both working rings on native', async () => {
+ await act(async () => {
+ renderer = create(
+ createElement('View', null, [
+ createElement(AgentSpinner, { key: 'spinner', status: 'working' }),
+ createElement(AgentStateDot, { key: 'dot', state: 'working' })
+ ])
+ )
+ })
+
+ expect(animationTiming).toHaveBeenCalledTimes(2)
+ for (const call of animationTiming.mock.calls) {
+ expect(call).toEqual([expect.anything(), expect.objectContaining({ useNativeDriver: true })])
+ }
+ })
+
it.each([
// A user's Stop is not news: muted, never the fault red a failure draws.
['interrupted', colors.textMuted],
diff --git a/mobile/src/components/use-working-ring-rotation.ts b/mobile/src/components/use-working-ring-rotation.ts
new file mode 100644
index 00000000000..b78b223e991
--- /dev/null
+++ b/mobile/src/components/use-working-ring-rotation.ts
@@ -0,0 +1,27 @@
+import { useEffect, useRef } from 'react'
+import { Animated, Easing, Platform } from 'react-native'
+
+/** The working ring's rotation: one turn per second while `active`, reset to 0 otherwise. */
+export function useWorkingRingRotation(active: boolean): Animated.AnimatedInterpolation {
+ const spinValue = useRef(new Animated.Value(0)).current
+
+ useEffect(() => {
+ if (active) {
+ const animation = Animated.loop(
+ Animated.timing(spinValue, {
+ toValue: 1,
+ duration: 1000,
+ easing: Easing.linear,
+ // Why: web has no native driver, so a loop over a native-driver timing plays it once.
+ useNativeDriver: Platform.OS !== 'web'
+ })
+ )
+ animation.start()
+ return () => animation.stop()
+ }
+ spinValue.setValue(0)
+ return undefined
+ }, [active, spinValue])
+
+ return spinValue.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] })
+}