From cffec8a37a61264626c1d58ee71193d3d26b8463 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 31 Aug 2026 22:51:07 -0400 Subject: [PATCH] feat(mobile): make hybrid architecture an explicit build mode --- .../mobile-hybrid-webview-architecture.md | 16 +++++++---- ...bile-hybrid-webview-single-pr-migration.md | 8 ++++-- mobile/README.md | 19 +++++++++---- mobile/app/_layout.tsx | 23 ++++++++++++--- .../mobile-native-baseline-mode.test.ts | 28 +++++++++++++++++-- .../mobile-web/mobile-native-baseline-mode.ts | 27 ++++++++++++++++-- .../mobile-web-home-navigation.test.ts | 5 ++-- .../mobile-web/mobile-web-home-navigation.ts | 9 +++++- .../notification-route-coordination.test.ts | 2 +- .../onboarding/mobile-onboarding-plan.test.ts | 2 +- .../src/onboarding/mobile-onboarding-plan.ts | 6 ++-- .../mobile-onboarding-screen.test.ts | 6 ++-- 12 files changed, 118 insertions(+), 33 deletions(-) diff --git a/docs/reference/mobile-hybrid-webview-architecture.md b/docs/reference/mobile-hybrid-webview-architecture.md index 8c3794cb0db..ca4fb5533a0 100644 --- a/docs/reference/mobile-hybrid-webview-architecture.md +++ b/docs/reference/mobile-hybrid-webview-architecture.md @@ -34,18 +34,22 @@ The architecture removes the broad workspace UI/RPC version boundary: ## Current Rollout State -The dedicated mobile release candidate always uses the production `/hybrid` -route for host-workspace destinations. It has no Experimental Settings entry, -environment-controlled route flag, or user-selectable native workspace -fallback. Restored legacy `/h/...` shell routes redirect to `/hybrid`. +The hybrid architecture is merged into the shared source tree but is selected +at mobile build time. Ordinary release builds default to the existing native +workspace routes. A dedicated candidate opts into `/hybrid` with +`EXPO_PUBLIC_ORCA_MOBILE_ARCHITECTURE=hybrid`; it has no user-selectable +fallback. Restored legacy `/h/...` shell routes redirect to `/hybrid` only in +that hybrid build. The screen modules under `mobile/app/h/` remain because the hosted React Native Web package imports that shared source. They are no longer native-shell workspace destinations. Use separate Desktop RC channels and TestFlight/internal mobile releases to -validate the hybrid-only candidate without placing two architectures in one -app. Promote the exact reviewed candidate only after the security, +validate the hybrid candidate while daily native mobile builds remain +unchanged. A current Desktop can serve both native and hybrid mobile builds; +pre-feature Desktops cannot serve the hybrid package. Promote the exact +reviewed candidate only after the security, physical-device, sustained-performance, rollback, and App Store gates in the active tracker pass. TestFlight evidence does not establish App Store acceptance. diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md index 6219798c5d5..d765a505572 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md @@ -44,9 +44,11 @@ Adopt the hybrid architecture conditionally: all gates in the [remaining-work tracker](2026-07-27-mobile-hybrid-webview-remaining-work.md) pass. -The dedicated candidate has no native workspace fallback. Native shell routes -remain for connection and device responsibilities; all workspace destinations -enter the hosted route. +The dedicated hybrid candidate has no native workspace fallback. The shared +tree also supports a native-default release mode so daily mobile builds remain +unchanged while the candidate is tested. Native shell routes remain for +connection and device responsibilities; hybrid workspace destinations enter +the hosted route. ## Rejected Alternatives diff --git a/mobile/README.md b/mobile/README.md index c26efd7a688..6b80bd864cc 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -2,9 +2,9 @@ React Native/Expo companion app for Orca Desktop. Pairing, encrypted connectivity, device capabilities, recovery, settings, and diagnostics remain -native. The production hybrid route renders the existing mobile workspace UI -through React Native Web from a verified package shipped with each paired -Desktop. +native. Ordinary release builds preserve the existing native workspace UI. A +dedicated hybrid release renders that same UI through React Native Web from a +verified package shipped with each paired Desktop. This is one shared UI, not a replacement web implementation. Read the [hybrid architecture reference](../docs/reference/mobile-hybrid-webview-architecture.md) @@ -128,8 +128,17 @@ that imports those screens and supplies hosted operation adapters. screen changes. Host, session, task, account, onboarding, notification, and cold-resume entry -always use the production hybrid route. There is no Experimental Settings -launcher or environment-controlled native workspace fallback. +follow the selected mobile architecture. Ordinary release builds default to +the native routes; a hybrid TestFlight/APK opts in at build time: + +```bash +EXPO_PUBLIC_ORCA_MOBILE_ARCHITECTURE=hybrid pnpm exec expo run:ios +EXPO_PUBLIC_ORCA_MOBILE_ARCHITECTURE=hybrid pnpm exec expo run:android +``` + +Unset or `native` keeps the current native workspace experience. The +development-only `EXPO_PUBLIC_ORCA_E2E_MOBILE_NATIVE_BASELINE=1` override still +selects native routes for parity captures. ## Package Build diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index fa34c248360..c14b90e63a4 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -22,7 +22,10 @@ import { retiredNativeWorkspaceHostId } from '../src/mobile-web/mobile-web-production-route' import { MOBILE_NATIVE_BASELINE_MODE } from '../src/mobile-web/mobile-native-baseline-mode' -import { mobileHostWorkspaceEntry } from '../src/mobile-web/mobile-web-home-navigation' +import { + mobileHomeDestination, + mobileHostWorkspaceEntry +} from '../src/mobile-web/mobile-web-home-navigation' import { loadHosts } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' @@ -49,7 +52,7 @@ Notifications.setNotificationHandler({ export default function RootLayout() { const router = useRouter() const pathname = usePathname() - const { notice } = useGlobalSearchParams<{ notice?: string }>() + const { hostId, notice } = useGlobalSearchParams<{ hostId?: string; notice?: string }>() const pathnameRef = useRef(pathname) const handledNotificationIdsRef = useRef>(new Set()) const notificationNavigationResolverRef = useRef( @@ -62,6 +65,10 @@ export default function RootLayout() { }, [pathname]) useEffect(() => { + if (MOBILE_NATIVE_BASELINE_MODE && pathname === '/hybrid') { + router.replace(hostId ? mobileHostWorkspaceEntry(hostId, true) : '/') + return + } if (isRetiredNativeWorkspaceRoute(pathname, MOBILE_NATIVE_BASELINE_MODE)) { const hostId = retiredNativeWorkspaceHostId(pathname) if (hostId && notice === 'worktree-missing') { @@ -73,7 +80,7 @@ export default function RootLayout() { } router.replace(hostId ? mobileHostWorkspaceEntry(hostId, false) : '/hybrid') } - }, [notice, pathname, router]) + }, [hostId, notice, pathname, router]) useEffect(() => { // Why: pairing publication is journaled across process death; startup must @@ -195,7 +202,15 @@ export default function RootLayout() { } MOBILE_WEB_NAVIGATION_INTENTS.publish(navigation.target) if (pathnameRef.current !== '/hybrid') { - router.push(mobileHostWorkspaceEntry(navigation.target.hostId, false)) + router.push( + mobileHomeDestination( + navigation.target.hostId, + navigation.target.kind === 'session' + ? { kind: 'session', hostWorkspaceId: navigation.target.hostWorkspaceId } + : { kind: 'workspaceList' }, + MOBILE_NATIVE_BASELINE_MODE + ) + ) } } } diff --git a/mobile/src/mobile-web/mobile-native-baseline-mode.test.ts b/mobile/src/mobile-web/mobile-native-baseline-mode.test.ts index bf64ee736d3..856f9276235 100644 --- a/mobile/src/mobile-web/mobile-native-baseline-mode.test.ts +++ b/mobile/src/mobile-web/mobile-native-baseline-mode.test.ts @@ -2,13 +2,35 @@ import { describe, expect, it } from 'vitest' import { mobileNativeBaselineMode } from './mobile-native-baseline-mode' describe('mobile native baseline mode', () => { - it('requires the exact runner flag in a development build', () => { + it('allows the exact runner flag in a development build', () => { expect(mobileNativeBaselineMode({ developmentBuild: true, requested: '1' })).toBe(true) expect(mobileNativeBaselineMode({ developmentBuild: true, requested: undefined })).toBe(false) expect(mobileNativeBaselineMode({ developmentBuild: true, requested: 'true' })).toBe(false) }) - it('cannot enable native workspace routes in production', () => { - expect(mobileNativeBaselineMode({ developmentBuild: false, requested: '1' })).toBe(false) + it('defaults release builds to native and development builds to hybrid', () => { + expect(mobileNativeBaselineMode({ developmentBuild: false, requested: undefined })).toBe(true) + expect(mobileNativeBaselineMode({ developmentBuild: true, requested: undefined })).toBe(false) + }) + + it('opts release builds into hybrid architecture explicitly', () => { + expect( + mobileNativeBaselineMode({ + developmentBuild: false, + requested: undefined, + architecture: 'hybrid' + }) + ).toBe(false) + expect( + mobileNativeBaselineMode({ + developmentBuild: false, + requested: undefined, + architecture: 'native' + }) + ).toBe(true) + }) + + it('does not allow the development baseline flag in production', () => { + expect(mobileNativeBaselineMode({ developmentBuild: false, requested: '1' })).toBe(true) }) }) diff --git a/mobile/src/mobile-web/mobile-native-baseline-mode.ts b/mobile/src/mobile-web/mobile-native-baseline-mode.ts index 5c0c12467fe..f9b39aa83c9 100644 --- a/mobile/src/mobile-web/mobile-native-baseline-mode.ts +++ b/mobile/src/mobile-web/mobile-native-baseline-mode.ts @@ -1,13 +1,36 @@ const NATIVE_BASELINE_FLAG = '1' +const NATIVE_ARCHITECTURE = 'native' +const HYBRID_ARCHITECTURE = 'hybrid' + +export type MobileArchitecture = 'native' | 'hybrid' export function mobileNativeBaselineMode(args: { developmentBuild: boolean requested: string | undefined + architecture?: string | undefined }): boolean { - return args.developmentBuild && args.requested === NATIVE_BASELINE_FLAG + // The E2E baseline override remains development-only so a production build + // cannot accidentally re-enable the retired workspace route. + if (args.developmentBuild && args.requested === NATIVE_BASELINE_FLAG) { + return true + } + if (args.architecture === NATIVE_ARCHITECTURE) { + return true + } + if (args.architecture === HYBRID_ARCHITECTURE) { + return false + } + // Development builds keep the existing hybrid test surface; release builds + // are native until a dedicated hybrid artifact opts in explicitly. + return !args.developmentBuild } export const MOBILE_NATIVE_BASELINE_MODE = mobileNativeBaselineMode({ developmentBuild: typeof __DEV__ !== 'undefined' && __DEV__, - requested: process.env.EXPO_PUBLIC_ORCA_E2E_MOBILE_NATIVE_BASELINE + requested: process.env.EXPO_PUBLIC_ORCA_E2E_MOBILE_NATIVE_BASELINE, + architecture: process.env.EXPO_PUBLIC_ORCA_MOBILE_ARCHITECTURE }) + +export const MOBILE_ARCHITECTURE: MobileArchitecture = MOBILE_NATIVE_BASELINE_MODE + ? 'native' + : 'hybrid' diff --git a/mobile/src/mobile-web/mobile-web-home-navigation.test.ts b/mobile/src/mobile-web/mobile-web-home-navigation.test.ts index c49442abe24..429b15ca846 100644 --- a/mobile/src/mobile-web/mobile-web-home-navigation.test.ts +++ b/mobile/src/mobile-web/mobile-web-home-navigation.test.ts @@ -20,13 +20,14 @@ afterEach(() => { }) describe('mobile web Home navigation', () => { - it('hands a typed destination to the production hosted route', () => { + it('hands a typed destination to the selected architecture route', () => { const router = { push: vi.fn() } navigateFromMobileHome({ router, hostId: 'host', - target: { kind: 'tasks', taskSource: 'linear' } + target: { kind: 'tasks', taskSource: 'linear' }, + nativeBaselineEnabled: false }) expect(router.push).toHaveBeenCalledWith('/hybrid?hostId=host') diff --git a/mobile/src/mobile-web/mobile-web-home-navigation.ts b/mobile/src/mobile-web/mobile-web-home-navigation.ts index 87da258a893..026097a1d1e 100644 --- a/mobile/src/mobile-web/mobile-web-home-navigation.ts +++ b/mobile/src/mobile-web/mobile-web-home-navigation.ts @@ -12,9 +12,16 @@ export function navigateFromMobileHome(args: { router: MobileHomeRouter hostId: string target: MobileWebNavigationIntentTarget + nativeBaselineEnabled?: boolean }): void { MOBILE_WEB_NAVIGATION_INTENTS.publishHostTarget(args.hostId, args.target) - args.router.push(mobileHomeDestination(args.hostId, args.target, MOBILE_NATIVE_BASELINE_MODE)) + args.router.push( + mobileHomeDestination( + args.hostId, + args.target, + args.nativeBaselineEnabled ?? MOBILE_NATIVE_BASELINE_MODE + ) + ) } export function mobileHostWorkspaceEntry( diff --git a/mobile/src/notifications/notification-route-coordination.test.ts b/mobile/src/notifications/notification-route-coordination.test.ts index 157dc72951a..79b8c99f7b6 100644 --- a/mobile/src/notifications/notification-route-coordination.test.ts +++ b/mobile/src/notifications/notification-route-coordination.test.ts @@ -53,7 +53,7 @@ describe('notification route coordination', () => { const notificationEffect = rootLayoutSource.slice(start, end) expect(notificationEffect).toContain('MOBILE_WEB_NAVIGATION_INTENTS.publish(navigation.target)') expect(notificationEffect).toContain( - 'router.push(mobileHostWorkspaceEntry(navigation.target.hostId, false))' + 'mobileHomeDestination(\n navigation.target.hostId' ) expect(notificationEffect).not.toContain('navigateToHostStackRoute(') }) diff --git a/mobile/src/onboarding/mobile-onboarding-plan.test.ts b/mobile/src/onboarding/mobile-onboarding-plan.test.ts index 569fcb5003a..be2d11c3ba4 100644 --- a/mobile/src/onboarding/mobile-onboarding-plan.test.ts +++ b/mobile/src/onboarding/mobile-onboarding-plan.test.ts @@ -57,7 +57,7 @@ describe('mobile onboarding plan', () => { } ] ] as const)('maps %j with host %s to the correct destination', (steps, hostId, destination) => { - expect(mobileOnboardingDestination(steps, hostId)).toEqual(destination) + expect(mobileOnboardingDestination(steps, hostId, false)).toEqual(destination) }) it('parses route steps in canonical order without duplicates', () => { diff --git a/mobile/src/onboarding/mobile-onboarding-plan.ts b/mobile/src/onboarding/mobile-onboarding-plan.ts index baabf505f0c..43ea107796d 100644 --- a/mobile/src/onboarding/mobile-onboarding-plan.ts +++ b/mobile/src/onboarding/mobile-onboarding-plan.ts @@ -1,6 +1,7 @@ import { shouldPresentNotificationOptIn } from '../notifications/notification-opt-in-gate' import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate' import { mobileHostWorkspaceEntry } from '../mobile-web/mobile-web-home-navigation' +import { MOBILE_NATIVE_BASELINE_MODE } from '../mobile-web/mobile-native-baseline-mode' export const MOBILE_ONBOARDING_STEPS = ['session-view', 'notifications'] as const export type MobileOnboardingStep = (typeof MOBILE_ONBOARDING_STEPS)[number] @@ -31,10 +32,11 @@ export async function loadMobileOnboardingSteps(): Promise { await act(async () => pages()[1].props.onNotificationChoice('skip')) expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled() expect(mocks.savePushNotificationsEnabled).toHaveBeenCalledWith(false) - expect(mocks.replace).toHaveBeenCalledWith('/hybrid?hostId=paired-host') + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') }) it('finishes immediately when the plan contains only one outstanding step', async () => { @@ -109,7 +109,7 @@ describe('MobileOnboardingScreen', () => { await renderScreen() await act(async () => pages()[0].props.onSessionChoice('terminal')) - expect(mocks.replace).toHaveBeenCalledWith('/hybrid?hostId=paired-host') + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') }) it('keeps the current step retryable when persistence fails', async () => { @@ -124,7 +124,7 @@ describe('MobileOnboardingScreen', () => { await act(async () => pages()[0].props.onSessionChoice('chat')) expect(mocks.saveDefaultSessionView).toHaveBeenCalledTimes(2) - expect(mocks.replace).toHaveBeenCalledWith('/hybrid?hostId=paired-host') + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') }) it('resets carousel state when the route supplies a new onboarding plan', async () => {