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'] }) +}