mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 16:02:25 +00:00
feat(mobile): make hybrid architecture an explicit build mode
This commit is contained in:
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user