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:
zhangxaochen
2026-10-23 19:21:08 -07:00
committed by GitHub
co-authored by Neil
parent 46a0844c99
commit c91a40c43d
2 changed files with 145 additions and 3 deletions
@@ -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()
}
})
})
+11 -3
View File
@@ -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 }) {