mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Keep mobile agent icons visible on retained routes (#31948)
* fix(mobile): preserve agent icons across stacked routes and tab updates Closes #31939 * fix(renderer): address review feedback on mobile agent icon stability * fix(renderer): fall back to customTitle when saved leafTitle is blank * Keep mobile agent icons painted across retained routes Use instance-specific OMP gradients and preserve bundled icon fallback during web image reload failures. Add a real mobile-web route-retention pixel regression. Adapted from zhangxaochen’s scoped rendering fixes in #31948; unproven session equality and title changes are excluded. Co-authored-by: zhangxaochen <zhangxaochen@163.com> --------- Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
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 pngjs from 'pngjs'
|
||||
import { buildMobileWebAppBundle } from '../build-mobile-web-app-bundle.mjs'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import {
|
||||
createBundleServer,
|
||||
installShellDouble,
|
||||
projectDir,
|
||||
readBridgeFaultGrant,
|
||||
readBridgeProtocolVersion,
|
||||
readShellCsp
|
||||
} from './preview/mobile-web-app-render-harness.mjs'
|
||||
|
||||
const describeRender = mobileWebAppDependenciesPresent() ? describe : describe.skip
|
||||
const pageRoutes = [
|
||||
{ pathname: '/h/[hostId]', grants: [] },
|
||||
{ pathname: '/h/[hostId]/session/[worktreeId]', grants: [] }
|
||||
]
|
||||
let scratch
|
||||
let server
|
||||
let browser
|
||||
let origin
|
||||
let init
|
||||
|
||||
function screenSource(name, target) {
|
||||
const source = join(projectDir, 'mobile/src')
|
||||
return `import { View, Text, Pressable } from 'react-native'
|
||||
import { MobileAgentIcon } from ${JSON.stringify(join(source, 'components/MobileAgentIcon'))}
|
||||
import { useRouteHandoff } from ${JSON.stringify(join(source, 'navigation/route-handoff'))}
|
||||
export default function Screen() {
|
||||
const router = useRouteHandoff()
|
||||
return <View testID="${name}" style={{ flex: 1, padding: 24, gap: 24 }}>
|
||||
<View testID="${name}-omp"><MobileAgentIcon agentId="omp" size={32} /></View>
|
||||
<View testID="${name}-gemini"><MobileAgentIcon agentId="gemini" size={32} /></View>
|
||||
<Pressable accessibilityRole="button" onPress={() => router.${target ? 'push' : 'back'}(${target ? JSON.stringify(target) : ''})}>
|
||||
<Text>${target ? 'Open session' : 'Back'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
}`
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!mobileWebAppDependenciesPresent()) {
|
||||
return
|
||||
}
|
||||
scratch = await mkdtemp(join(tmpdir(), 'orca-agent-icon-render-'))
|
||||
const appDir = join(scratch, 'app')
|
||||
const routeDir = join(appDir, 'h')
|
||||
await mkdir(join(routeDir, '[hostId]/session'), { recursive: true })
|
||||
await writeFile(
|
||||
join(routeDir, '_layout.tsx'),
|
||||
`import { HostStack } from ${JSON.stringify(join(projectDir, 'mobile/src/navigation/host-stack'))}
|
||||
export default function Layout() { return <HostStack animation="default" /> }`
|
||||
)
|
||||
await writeFile(
|
||||
join(routeDir, '[hostId]/index.tsx'),
|
||||
screenSource('list', '/h/icon-host/session/icon-workspace')
|
||||
)
|
||||
await writeFile(join(routeDir, '[hostId]/session/[worktreeId].tsx'), screenSource('session'))
|
||||
const { outDir } = await buildMobileWebAppBundle({
|
||||
appDir,
|
||||
outDir: join(scratch, 'bundle'),
|
||||
pageRoutes
|
||||
})
|
||||
const served = await createBundleServer({
|
||||
outDir,
|
||||
cspHeader: await readShellCsp()
|
||||
})
|
||||
server = served.server
|
||||
origin = served.origin
|
||||
init = {
|
||||
version: await readBridgeProtocolVersion(),
|
||||
sessionId: 'agent-icons',
|
||||
buildId: 'agent-icons',
|
||||
route: { pathname: '/h/icon-host', params: {} },
|
||||
host: { id: 'icon-host', name: 'Icon Host', endpoint: 'ws://icons', lastConnected: 1 },
|
||||
storage: {},
|
||||
faultGrant: await readBridgeFaultGrant(),
|
||||
pageRoutes: pageRoutes.map((route) => route.pathname),
|
||||
replies: {}
|
||||
}
|
||||
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
||||
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
server?.close()
|
||||
if (scratch) {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function openList() {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
||||
await page.addInitScript(installShellDouble, init)
|
||||
await page.goto(origin)
|
||||
await page.getByTestId('list').waitFor({ state: 'visible' })
|
||||
await page.waitForFunction(() => document.querySelector('img')?.naturalWidth > 0)
|
||||
return page
|
||||
}
|
||||
|
||||
async function coloredPixels(locator) {
|
||||
const image = pngjs.PNG.sync.read(await locator.screenshot())
|
||||
let count = 0
|
||||
for (let i = 0; i < image.data.length; i += 4) {
|
||||
const channels = image.data.subarray(i, i + 3)
|
||||
if (Math.max(...channels) - Math.min(...channels) > 30) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
describeRender('mobile agent icons on retained routes', () => {
|
||||
it('paints the active OMP mark after the real stack hides its retained parent', async () => {
|
||||
const page = await openList()
|
||||
try {
|
||||
expect(await coloredPixels(page.getByTestId('list-omp'))).toBeGreaterThan(0)
|
||||
await page.getByRole('button', { name: 'Open session' }).click()
|
||||
await page.getByTestId('session').waitFor({ state: 'visible' })
|
||||
await page.getByTestId('list').waitFor({ state: 'hidden' })
|
||||
expect(await page.getByTestId('list').count()).toBe(1)
|
||||
expect(await page.getByTestId('list').isVisible()).toBe(false)
|
||||
expect(await coloredPixels(page.getByTestId('session-omp'))).toBeGreaterThan(0)
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from 'react'
|
||||
import { Image, StyleSheet, Text, View } from 'react-native'
|
||||
import type { ImageSourcePropType } from 'react-native'
|
||||
import { Terminal } from 'lucide-react-native'
|
||||
@@ -28,16 +29,17 @@ function PiIcon({ size = 16 }: { size?: number }) {
|
||||
function OmpIcon({ size = 16 }: { size?: number }) {
|
||||
// SVG sourced from omp.sh's transparent homepage mark. Why: react-native-svg
|
||||
// does not support the homepage's CSS oklch stops, so use its favicon hex stops.
|
||||
const gradientId = `ompMarkGradient-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 64 64">
|
||||
<Defs>
|
||||
<LinearGradient id="ompMarkGradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<LinearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
|
||||
<Stop offset="0" stopColor="#ed4abf" />
|
||||
<Stop offset="0.5" stopColor="#9b4dff" />
|
||||
<Stop offset="1" stopColor="#5ad8e6" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Path fill="url(#ompMarkGradient)" d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
|
||||
<Path fill={`url(#${gradientId})`} d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@@ -54,7 +56,13 @@ function AiderIcon({ size = 16 }: { size?: number }) {
|
||||
}
|
||||
|
||||
function BundledIcon({ source, size = 16 }: { source: ImageSourcePropType; size?: number }) {
|
||||
return <Image source={source} style={{ width: size, height: size, borderRadius: 2 }} />
|
||||
return (
|
||||
<Image
|
||||
source={source}
|
||||
defaultSource={Array.isArray(source) ? undefined : source}
|
||||
style={{ width: size, height: size, borderRadius: 2 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) {
|
||||
|
||||
Reference in New Issue
Block a user