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
This commit is contained in:
Jinwoo Hong
2026-10-04 19:12:00 -04:00
committed by GitHub
parent 3b01ba6d0d
commit 1978469fd2
5 changed files with 195 additions and 44 deletions
@@ -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 (
<View style={{ padding: 24, gap: 24 }}>
<View nativeID="working-spinner"><AgentSpinner status="working" /></View>
<View nativeID="working-dot"><AgentStateDot state="working" /></View>
</View>
)
}
`
}
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)
})
+3 -23
View File
@@ -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 (
<View style={styles.wrapper}>
<Animated.View style={[styles.spinner, { borderColor: color, transform: [{ rotate }] }]} />
+3 -21
View File
@@ -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<Exclude<AgentDotState, 'working' | 'monitoring'>, 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 (
<View style={styles.wrapper}>
<Animated.View style={[styles.spinner, { transform: [{ rotate }] }]} />
@@ -32,6 +32,7 @@ vi.mock('react-native', () => ({
timing: animationTiming
},
Easing: { linear: 'linear' },
Platform: { OS: 'ios' },
StyleSheet: { create: <T>(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],
@@ -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<string> {
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'] })
}