feat(mobile): make hybrid architecture an explicit build mode

This commit is contained in:
Jinwoo-H
2026-08-31 22:51:07 -04:00
parent a9d48c6345
commit cffec8a37a
12 changed files with 118 additions and 33 deletions
@@ -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.
@@ -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
+14 -5
View File
@@ -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
+19 -4
View File
@@ -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<Set<string>>(new Set())
const notificationNavigationResolverRef = useRef<LatestNotificationNavigationResolver | null>(
@@ -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
)
)
}
}
}
@@ -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)
})
})
@@ -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'
@@ -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')
@@ -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(
@@ -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(')
})
@@ -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', () => {
@@ -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<MobileOnboardingStep[
/** Preserves a paired host while routing through outstanding decisions. */
export function mobileOnboardingDestination(
steps: readonly MobileOnboardingStep[],
hostId?: string
hostId?: string,
nativeBaselineEnabled = MOBILE_NATIVE_BASELINE_MODE
): MobileOnboardingDestination {
if (steps.length === 0) {
return hostId ? mobileHostWorkspaceEntry(hostId) : '/'
return hostId ? mobileHostWorkspaceEntry(hostId, nativeBaselineEnabled) : '/'
}
return {
pathname: '/mobile-onboarding',
@@ -101,7 +101,7 @@ describe('MobileOnboardingScreen', () => {
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 () => {