From c91a40c43d775f69f677c86a938cdf2d33e86f55 Mon Sep 17 00:00:00 2001 From: zhangxaochen Date: Sat, 24 Oct 2026 10:21:08 +0800 Subject: [PATCH] Keep mobile agent icons visible on retained routes (#31948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --------- Co-authored-by: Neil --- .../mobile-web-app-agent-icon-render.test.mjs | 134 ++++++++++++++++++ mobile/src/components/MobileAgentIcon.tsx | 14 +- 2 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 config/scripts/mobile-web-app/mobile-web-app-agent-icon-render.test.mjs diff --git a/config/scripts/mobile-web-app/mobile-web-app-agent-icon-render.test.mjs b/config/scripts/mobile-web-app/mobile-web-app-agent-icon-render.test.mjs new file mode 100644 index 00000000000..c2885cebe1b --- /dev/null +++ b/config/scripts/mobile-web-app/mobile-web-app-agent-icon-render.test.mjs @@ -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 + + + router.${target ? 'push' : 'back'}(${target ? JSON.stringify(target) : ''})}> + ${target ? 'Open session' : 'Back'} + + +}` +} + +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 }` + ) + 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() + } + }) +}) diff --git a/mobile/src/components/MobileAgentIcon.tsx b/mobile/src/components/MobileAgentIcon.tsx index ff43c35de5a..4374d9605a2 100644 --- a/mobile/src/components/MobileAgentIcon.tsx +++ b/mobile/src/components/MobileAgentIcon.tsx @@ -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 ( - + - + ) } @@ -54,7 +56,13 @@ function AiderIcon({ size = 16 }: { size?: number }) { } function BundledIcon({ source, size = 16 }: { source: ImageSourcePropType; size?: number }) { - return + return ( + + ) } function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) {