mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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:
@@ -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)
|
||||
})
|
||||
@@ -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 }] }]} />
|
||||
|
||||
@@ -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'] })
|
||||
}
|
||||
Reference in New Issue
Block a user