From ccd72f5909ac87dec5a42e523b15935d9cfebcb4 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:11:23 -0700
Subject: [PATCH] Add unified mobile onboarding for session view and
notifications (#9478)
* Add a mobile native-chat opt-in so users pick terminal vs chat once
Mirror the notifications one-time opt-in for the native-chat default view.
After pairing, a full-screen modal (modeled on notification-opt-in) lets the
user choose whether supported agent sessions open in the terminal or in native
chat, then persists the choice to the existing orca:defaultSessionView key.
- Expose readDefaultSessionViewPreference() (tri-state; absent key = undecided)
so the gate can prompt exactly once; loadDefaultSessionView() is unchanged.
- shouldPresentSessionViewOptIn() gates the screen; the home focus effect shows
it after the notification opt-in.
- Settings -> Native chat toggle (already shipped) remains the recovery path.
* fix(mobile): preserve onboarding flow after pairing
* refine mobile session view opt-in copy
* Unify mobile onboarding prompts
---
mobile/app/_layout.tsx | 2 +-
mobile/app/index.tsx | 22 +-
mobile/app/mobile-onboarding.tsx | 215 ++++++++++++++++
mobile/app/notification-opt-in.tsx | 236 ------------------
mobile/app/pair-confirm.tsx | 13 +-
mobile/app/pair-scan.tsx | 13 +-
.../onboarding/MobileOnboardingPage.test.ts | 103 ++++++++
.../src/onboarding/MobileOnboardingPage.tsx | 173 +++++++++++++
.../onboarding/mobile-onboarding-plan.test.ts | 75 ++++++
.../src/onboarding/mobile-onboarding-plan.ts | 51 ++++
.../mobile-onboarding-screen.test.ts | 165 ++++++++++++
.../onboarding/mobile-onboarding-styles.ts | 132 ++++++++++
.../session/session-view-opt-in-gate.test.ts | 49 ++++
.../src/session/session-view-opt-in-gate.ts | 10 +
mobile/src/storage/preferences.test.ts | 34 +++
.../src/storage/session-view-preferences.ts | 23 +-
16 files changed, 1054 insertions(+), 262 deletions(-)
create mode 100644 mobile/app/mobile-onboarding.tsx
delete mode 100644 mobile/app/notification-opt-in.tsx
create mode 100644 mobile/src/onboarding/MobileOnboardingPage.test.ts
create mode 100644 mobile/src/onboarding/MobileOnboardingPage.tsx
create mode 100644 mobile/src/onboarding/mobile-onboarding-plan.test.ts
create mode 100644 mobile/src/onboarding/mobile-onboarding-plan.ts
create mode 100644 mobile/src/onboarding/mobile-onboarding-screen.test.ts
create mode 100644 mobile/src/onboarding/mobile-onboarding-styles.ts
create mode 100644 mobile/src/session/session-view-opt-in-gate.test.ts
create mode 100644 mobile/src/session/session-view-opt-in-gate.ts
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index fe89a82c4..d5cbd0e0f 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -179,7 +179,7 @@ export default function RootLayout() {
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index 265e3413c..a8909c372 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -36,7 +36,10 @@ import {
} from '../src/transport/client-context'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
-import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
+import {
+ loadMobileOnboardingSteps,
+ mobileOnboardingDestination
+} from '../src/onboarding/mobile-onboarding-plan'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
@@ -306,7 +309,9 @@ export default function HomeScreen() {
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
null
)
- const notificationOptInCheckedRef = useRef(false)
+ // Why: focus can fire repeatedly while an async gate is pending; one probe per
+ // mount avoids duplicate storage/permission reads and competing navigation.
+ const onboardingOptInCheckedRef = useRef(false)
// Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
@@ -378,13 +383,16 @@ export default function HomeScreen() {
return
}
setHosts(h)
- if (h.length === 0 || notificationOptInCheckedRef.current) {
+ if (h.length === 0 || onboardingOptInCheckedRef.current) {
return
}
- notificationOptInCheckedRef.current = true
- const showNotificationOptIn = await shouldPresentNotificationOptIn()
- if (!stale && showNotificationOptIn) {
- router.replace('/notification-opt-in')
+ onboardingOptInCheckedRef.current = true
+ const onboardingSteps = await loadMobileOnboardingSteps()
+ if (stale) {
+ return
+ }
+ if (onboardingSteps.length > 0) {
+ router.replace(mobileOnboardingDestination(onboardingSteps))
}
})
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
diff --git a/mobile/app/mobile-onboarding.tsx b/mobile/app/mobile-onboarding.tsx
new file mode 100644
index 000000000..50957a465
--- /dev/null
+++ b/mobile/app/mobile-onboarding.tsx
@@ -0,0 +1,215 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ AccessibilityInfo,
+ Animated,
+ BackHandler,
+ Text,
+ useWindowDimensions,
+ View
+} from 'react-native'
+import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
+import { SafeAreaView } from 'react-native-safe-area-context'
+import { OrcaLogo } from '../src/components/OrcaLogo'
+import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications'
+import {
+ MobileOnboardingPage,
+ type MobileOnboardingBusyChoice,
+ type NotificationOnboardingChoice
+} from '../src/onboarding/MobileOnboardingPage'
+import { parseMobileOnboardingSteps } from '../src/onboarding/mobile-onboarding-plan'
+import { mobileOnboardingStyles as styles } from '../src/onboarding/mobile-onboarding-styles'
+import {
+ saveDefaultSessionView,
+ type MobileSessionView
+} from '../src/storage/session-view-preferences'
+import { savePushNotificationsEnabled } from '../src/storage/preferences'
+
+const SLIDE_DURATION_MS = 280
+
+export default function MobileOnboardingScreen() {
+ const params = useLocalSearchParams<{
+ hostId?: string | string[]
+ steps?: string | string[]
+ }>()
+ const hostId = firstParam(params.hostId)
+ const rawSteps = firstParam(params.steps)
+
+ return (
+
+ )
+}
+
+function MobileOnboardingFlow({
+ hostId,
+ rawSteps
+}: {
+ hostId: string | undefined
+ rawSteps: string | undefined
+}) {
+ const router = useRouter()
+ const steps = useMemo(() => parseMobileOnboardingSteps(rawSteps), [rawSteps])
+ const { width } = useWindowDimensions()
+ const [activeIndex, setActiveIndex] = useState(0)
+ const [busyChoice, setBusyChoice] = useState(null)
+ const [error, setError] = useState(null)
+ const choiceInFlightRef = useRef(false)
+ const slideProgress = useRef(new Animated.Value(0)).current
+ const reducedMotionEnabled = useReducedMotionEnabled()
+
+ // Why: onboarding requires an explicit choice for every planned step; disabling
+ // stack gestures alone still leaves Android hardware Back able to skip the flow.
+ useFocusEffect(
+ useCallback(() => {
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => true)
+ return () => subscription.remove()
+ }, [])
+ )
+
+ const continueToApp = useCallback(() => {
+ router.replace(hostId ? `/h/${hostId}` : '/')
+ }, [hostId, router])
+
+ const advanceOrContinue = useCallback(() => {
+ const nextIndex = activeIndex + 1
+ if (nextIndex >= steps.length) {
+ continueToApp()
+ return
+ }
+ setActiveIndex(nextIndex)
+ setError(null)
+ Animated.timing(slideProgress, {
+ toValue: nextIndex,
+ // Why: the carousel should preserve continuity without overriding the
+ // device's reduced-motion preference.
+ duration: reducedMotionEnabled ? 0 : SLIDE_DURATION_MS,
+ useNativeDriver: true
+ }).start(() => {
+ // Why: a cancelled cosmetic transition must not leave the next decision
+ // permanently disabled after the previous choice was already persisted.
+ setBusyChoice(null)
+ choiceInFlightRef.current = false
+ })
+ }, [activeIndex, continueToApp, reducedMotionEnabled, slideProgress, steps.length])
+
+ const chooseSessionView = useCallback(
+ async (view: MobileSessionView) => {
+ // Why: state does not disable both buttons synchronously; the ref prevents
+ // rapid taps from persisting conflicting choices or advancing twice.
+ if (choiceInFlightRef.current) {
+ return
+ }
+ choiceInFlightRef.current = true
+ setBusyChoice(view)
+ setError(null)
+ try {
+ await saveDefaultSessionView(view)
+ advanceOrContinue()
+ } catch {
+ setError('Your choice could not be saved. Try again.')
+ setBusyChoice(null)
+ choiceInFlightRef.current = false
+ }
+ },
+ [advanceOrContinue]
+ )
+
+ const chooseNotifications = useCallback(
+ async (choice: NotificationOnboardingChoice) => {
+ if (choiceInFlightRef.current) {
+ return
+ }
+ choiceInFlightRef.current = true
+ setBusyChoice(choice)
+ setError(null)
+ try {
+ const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false
+ await savePushNotificationsEnabled(enabled)
+ advanceOrContinue()
+ } catch {
+ setError('Notification settings could not be updated. Try again.')
+ setBusyChoice(null)
+ choiceInFlightRef.current = false
+ }
+ },
+ [advanceOrContinue]
+ )
+
+ const translateX = useMemo(() => Animated.multiply(slideProgress, -width), [slideProgress, width])
+
+ return (
+
+
+
+ Orca
+ {steps.length > 1 ? (
+
+ {steps.map((step, index) => (
+
+ ))}
+
+ ) : null}
+
+
+
+
+ {steps.map((step, index) => (
+ void chooseSessionView(view)}
+ onNotificationChoice={(choice) => void chooseNotifications(choice)}
+ />
+ ))}
+
+
+
+ )
+}
+
+function firstParam(value: string | string[] | undefined): string | undefined {
+ return Array.isArray(value) ? value[0] : value
+}
+
+function useReducedMotionEnabled(): boolean {
+ const [enabled, setEnabled] = useState(false)
+
+ useEffect(() => {
+ let mounted = true
+ void AccessibilityInfo.isReduceMotionEnabled()
+ .then((nextEnabled) => {
+ if (mounted) {
+ setEnabled(nextEnabled)
+ }
+ })
+ .catch(() => undefined)
+ const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setEnabled)
+ return () => {
+ mounted = false
+ subscription.remove()
+ }
+ }, [])
+
+ return enabled
+}
diff --git a/mobile/app/notification-opt-in.tsx b/mobile/app/notification-opt-in.tsx
deleted file mode 100644
index a4476687e..000000000
--- a/mobile/app/notification-opt-in.tsx
+++ /dev/null
@@ -1,236 +0,0 @@
-import { useCallback, useState } from 'react'
-import {
- ActivityIndicator,
- BackHandler,
- Pressable,
- ScrollView,
- StyleSheet,
- Text,
- View
-} from 'react-native'
-import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
-import { SafeAreaView } from 'react-native-safe-area-context'
-import { BellRing } from 'lucide-react-native'
-import { OrcaLogo } from '../src/components/OrcaLogo'
-import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications'
-import { savePushNotificationsEnabled } from '../src/storage/preferences'
-import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
-
-export default function NotificationOptInScreen() {
- const router = useRouter()
- const params = useLocalSearchParams<{ hostId?: string | string[] }>()
- const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId
- const [busyChoice, setBusyChoice] = useState<'enable' | 'skip' | null>(null)
- const [error, setError] = useState(null)
-
- // Why: this one-time screen requires an explicit Enable or Not now choice;
- // disabling back gestures alone would still leave Android hardware back open.
- useFocusEffect(
- useCallback(() => {
- const subscription = BackHandler.addEventListener('hardwareBackPress', () => true)
- return () => subscription.remove()
- }, [])
- )
-
- const continueToApp = useCallback(() => {
- router.replace(hostId ? `/h/${hostId}` : '/')
- }, [hostId, router])
-
- const choose = useCallback(
- async (choice: 'enable' | 'skip') => {
- if (busyChoice) {
- return
- }
- setBusyChoice(choice)
- setError(null)
- try {
- const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false
- await savePushNotificationsEnabled(enabled)
- continueToApp()
- } catch {
- setError('Notification settings could not be updated. Try again.')
- setBusyChoice(null)
- }
- },
- [busyChoice, continueToApp]
- )
-
- return (
-
-
-
-
- Orca
-
-
-
-
-
-
- Notifications
- Stay updated while away
-
- Get notified on this device when an agent needs your input or finishes a task.
-
-
-
-
- {error ? (
-
- {error}
-
- ) : null}
- [
- styles.primaryButton,
- pressed && styles.buttonPressed,
- busyChoice !== null && styles.buttonDisabled
- ]}
- onPress={() => void choose('enable')}
- >
- {busyChoice === 'enable' ? (
-
- ) : (
- Enable notifications
- )}
-
- [
- styles.secondaryButton,
- pressed && styles.buttonPressed,
- busyChoice !== null && styles.buttonDisabled
- ]}
- onPress={() => void choose('skip')}
- >
- {busyChoice === 'skip' ? (
-
- ) : (
- Not now
- )}
-
- You can change this any time in Settings.
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase,
- paddingHorizontal: spacing.xl
- },
- // Why: this decision screen cannot be dismissed with Back, so every action
- // must remain reachable in landscape and with accessibility text scaling.
- scrollContent: {
- flexGrow: 1
- },
- brandRow: {
- minHeight: 52,
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- brandName: {
- color: colors.textPrimary,
- fontSize: 17,
- fontWeight: '700'
- },
- content: {
- flexGrow: 1,
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: spacing.xl
- },
- iconSurface: {
- width: 64,
- height: 64,
- borderRadius: radii.card,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: colors.bgRaised,
- marginBottom: spacing.xl
- },
- eyebrow: {
- color: colors.textMuted,
- fontSize: 11,
- fontWeight: '600',
- letterSpacing: 0.55,
- textTransform: 'uppercase',
- marginBottom: spacing.sm
- },
- title: {
- maxWidth: 420,
- color: colors.textPrimary,
- fontSize: 26,
- fontWeight: '700',
- letterSpacing: -0.3,
- textAlign: 'center'
- },
- body: {
- maxWidth: 420,
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- lineHeight: 21,
- textAlign: 'center',
- marginTop: spacing.md
- },
- footer: {
- width: '100%',
- maxWidth: 420,
- alignSelf: 'center',
- paddingBottom: spacing.lg
- },
- primaryButton: {
- minHeight: 44,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button,
- backgroundColor: colors.surfaceBright,
- paddingVertical: spacing.sm
- },
- primaryButtonText: {
- color: colors.bgBase,
- fontSize: typography.bodySize,
- fontWeight: '600'
- },
- secondaryButton: {
- minHeight: 44,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button,
- marginTop: spacing.xs,
- paddingVertical: spacing.sm
- },
- secondaryButtonText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- fontWeight: '500'
- },
- buttonPressed: {
- opacity: 0.72
- },
- buttonDisabled: {
- opacity: 0.58
- },
- footerNote: {
- color: colors.textMuted,
- fontSize: typography.metaSize,
- lineHeight: 18,
- textAlign: 'center',
- marginTop: spacing.sm
- },
- error: {
- color: colors.statusRed,
- fontSize: typography.metaSize,
- lineHeight: 18,
- textAlign: 'center',
- marginBottom: spacing.sm
- }
-})
diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx
index d4505b824..0621caa4f 100644
--- a/mobile/app/pair-confirm.tsx
+++ b/mobile/app/pair-confirm.tsx
@@ -12,7 +12,10 @@ import type { ConnectionLogEntry } from '../src/transport/types'
import { useCloseHost } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
-import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
+import {
+ loadMobileOnboardingSteps,
+ mobileOnboardingDestination
+} from '../src/onboarding/mobile-onboarding-plan'
type Status = 'awaiting-confirm' | 'connecting' | 'error'
@@ -113,15 +116,11 @@ export default function PairConfirmScreen() {
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
- const showNotificationOptIn = await shouldPresentNotificationOptIn()
+ const onboardingSteps = await loadMobileOnboardingSteps()
if (!mountedRef.current) {
return
}
- router.replace(
- showNotificationOptIn
- ? { pathname: '/notification-opt-in', params: { hostId } }
- : `/h/${hostId}`
- )
+ router.replace(mobileOnboardingDestination(onboardingSteps, hostId))
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx
index 6b980ee0b..892c1b359 100644
--- a/mobile/app/pair-scan.tsx
+++ b/mobile/app/pair-scan.tsx
@@ -22,7 +22,10 @@ import { useCloseHost } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
-import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
+import {
+ loadMobileOnboardingSteps,
+ mobileOnboardingDestination
+} from '../src/onboarding/mobile-onboarding-plan'
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
// route surfaces as a real error with the log visible instead of a
@@ -157,15 +160,11 @@ export default function PairScanScreen() {
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
- const showNotificationOptIn = await shouldPresentNotificationOptIn()
+ const onboardingSteps = await loadMobileOnboardingSteps()
if (!mountedRef.current) {
return
}
- router.replace(
- showNotificationOptIn
- ? { pathname: '/notification-opt-in', params: { hostId } }
- : `/h/${hostId}`
- )
+ router.replace(mobileOnboardingDestination(onboardingSteps, hostId))
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
diff --git a/mobile/src/onboarding/MobileOnboardingPage.test.ts b/mobile/src/onboarding/MobileOnboardingPage.test.ts
new file mode 100644
index 000000000..f36831051
--- /dev/null
+++ b/mobile/src/onboarding/MobileOnboardingPage.test.ts
@@ -0,0 +1,103 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { MobileOnboardingPage } from './MobileOnboardingPage'
+
+vi.mock('react-native', async () => {
+ const React = await import('react')
+ return {
+ ActivityIndicator: 'ActivityIndicator',
+ Pressable: 'Pressable',
+ ScrollView: ({ children, ...props }: { children?: unknown }) =>
+ React.createElement('ScrollView', props, children),
+ StyleSheet: { create: (styles: unknown) => styles },
+ Text: 'Text',
+ View: 'View'
+ }
+})
+
+vi.mock('lucide-react-native', () => ({
+ BellRing: 'BellRing',
+ MessageSquare: 'MessageSquare'
+}))
+
+describe('MobileOnboardingPage', () => {
+ let renderer: ReactTestRenderer | null = null
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ vi.restoreAllMocks()
+ })
+
+ async function renderPage(
+ step: 'session-view' | 'notifications',
+ options: { active?: boolean; busyChoice?: 'chat' | 'enable' | null } = {}
+ ) {
+ const onSessionChoice = vi.fn()
+ const onNotificationChoice = vi.fn()
+ const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
+ throw new Error(String(args[0]))
+ }
+ })
+ await act(async () => {
+ renderer = create(
+ createElement(MobileOnboardingPage, {
+ step,
+ width: 390,
+ active: options.active ?? true,
+ busyChoice: options.busyChoice ?? null,
+ error: null,
+ onSessionChoice,
+ onNotificationChoice
+ })
+ )
+ })
+ consoleError.mockRestore()
+ return { onSessionChoice, onNotificationChoice }
+ }
+
+ function button(label: string) {
+ return renderer!.root.find(
+ (node) => node.type === 'Pressable' && node.props.accessibilityLabel === label
+ )
+ }
+
+ it('renders the session choices and sends exactly one selected view', async () => {
+ const callbacks = await renderPage('session-view')
+
+ act(() => button('Open sessions in native chat').props.onPress())
+ expect(callbacks.onSessionChoice).toHaveBeenCalledWith('chat')
+ expect(callbacks.onNotificationChoice).not.toHaveBeenCalled()
+ })
+
+ it('renders the notification choices and sends the selected option', async () => {
+ const callbacks = await renderPage('notifications')
+ act(() => button('Skip notifications for now').props.onPress())
+
+ expect(callbacks.onNotificationChoice).toHaveBeenCalledWith('skip')
+ expect(callbacks.onSessionChoice).not.toHaveBeenCalled()
+ })
+
+ it('disables both notification choices while permission is pending', async () => {
+ await renderPage('notifications', { busyChoice: 'enable' })
+ const enable = button('Enable agent notifications')
+ const secondary = button('Skip notifications for now')
+
+ expect(enable.props.disabled).toBe(true)
+ expect(secondary.props.disabled).toBe(true)
+ })
+
+ it('hides an off-screen page from assistive technology', async () => {
+ await renderPage('notifications', { active: false })
+ const scrollView = renderer!.root.findByType('ScrollView')
+
+ expect(scrollView.props.accessibilityElementsHidden).toBe(true)
+ expect(scrollView.props.importantForAccessibility).toBe('no-hide-descendants')
+ })
+})
diff --git a/mobile/src/onboarding/MobileOnboardingPage.tsx b/mobile/src/onboarding/MobileOnboardingPage.tsx
new file mode 100644
index 000000000..f7bbff0cf
--- /dev/null
+++ b/mobile/src/onboarding/MobileOnboardingPage.tsx
@@ -0,0 +1,173 @@
+import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'
+import { BellRing, MessageSquare } from 'lucide-react-native'
+import type { MobileOnboardingStep } from './mobile-onboarding-plan'
+import { mobileOnboardingStyles as styles } from './mobile-onboarding-styles'
+import type { MobileSessionView } from '../storage/session-view-preferences'
+import { colors } from '../theme/mobile-theme'
+
+export type NotificationOnboardingChoice = 'enable' | 'skip'
+export type MobileOnboardingBusyChoice = MobileSessionView | NotificationOnboardingChoice | null
+
+type Props = {
+ step: MobileOnboardingStep
+ width: number
+ active: boolean
+ busyChoice: MobileOnboardingBusyChoice
+ error: string | null
+ onSessionChoice: (view: MobileSessionView) => void
+ onNotificationChoice: (choice: NotificationOnboardingChoice) => void
+}
+
+export function MobileOnboardingPage({
+ step,
+ width,
+ active,
+ busyChoice,
+ error,
+ onSessionChoice,
+ onNotificationChoice
+}: Props) {
+ const busy = busyChoice !== null
+ const isSessionView = step === 'session-view'
+
+ return (
+
+
+
+ {isSessionView ? (
+
+ ) : (
+
+ )}
+
+
+ {isSessionView ? 'How should sessions open?' : 'Stay updated while away'}
+
+
+ {isSessionView
+ ? 'Choose whether supported agent sessions open in the terminal or native chat on this device. Press and hold a session tab to switch its view, or change the default later in Settings.'
+ : 'Get notified on this device when an agent needs your input or finishes a task.'}
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {isSessionView ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+function SessionViewChoices({
+ busyChoice,
+ disabled,
+ onChoice
+}: {
+ busyChoice: MobileOnboardingBusyChoice
+ disabled: boolean
+ onChoice: (view: MobileSessionView) => void
+}) {
+ return (
+ <>
+ onChoice('chat')}
+ />
+ onChoice('terminal')}
+ />
+ >
+ )
+}
+
+function NotificationChoices({
+ busyChoice,
+ disabled,
+ onChoice
+}: {
+ busyChoice: MobileOnboardingBusyChoice
+ disabled: boolean
+ onChoice: (choice: NotificationOnboardingChoice) => void
+}) {
+ return (
+ <>
+ onChoice('enable')}
+ />
+ onChoice('skip')}
+ />
+ >
+ )
+}
+
+function ChoiceButton({
+ label,
+ accessibilityLabel,
+ primary = false,
+ busy,
+ disabled,
+ onPress
+}: {
+ label: string
+ accessibilityLabel?: string
+ primary?: boolean
+ busy: boolean
+ disabled: boolean
+ onPress: () => void
+}) {
+ return (
+ [
+ primary ? styles.primaryButton : styles.secondaryButton,
+ pressed && styles.buttonPressed,
+ disabled && styles.buttonDisabled
+ ]}
+ onPress={onPress}
+ >
+ {busy ? (
+
+ ) : (
+ {label}
+ )}
+
+ )
+}
diff --git a/mobile/src/onboarding/mobile-onboarding-plan.test.ts b/mobile/src/onboarding/mobile-onboarding-plan.test.ts
new file mode 100644
index 000000000..d04396bde
--- /dev/null
+++ b/mobile/src/onboarding/mobile-onboarding-plan.test.ts
@@ -0,0 +1,75 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { shouldPresentNotificationOptIn } from '../notifications/notification-opt-in-gate'
+import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate'
+import {
+ loadMobileOnboardingSteps,
+ mobileOnboardingDestination,
+ parseMobileOnboardingSteps
+} from './mobile-onboarding-plan'
+
+vi.mock('../notifications/notification-opt-in-gate', () => ({
+ shouldPresentNotificationOptIn: vi.fn()
+}))
+vi.mock('../session/session-view-opt-in-gate', () => ({
+ shouldPresentSessionViewOptIn: vi.fn()
+}))
+
+describe('mobile onboarding plan', () => {
+ beforeEach(() => {
+ vi.mocked(shouldPresentNotificationOptIn).mockReset().mockResolvedValue(false)
+ vi.mocked(shouldPresentSessionViewOptIn).mockReset().mockResolvedValue(false)
+ })
+
+ it.each([
+ [true, true, ['session-view', 'notifications']],
+ [true, false, ['session-view']],
+ [false, true, ['notifications']],
+ [false, false, []]
+ ] as const)(
+ 'builds the exact plan for session=%s notifications=%s',
+ async (showSession, showNotifications, expected) => {
+ vi.mocked(shouldPresentSessionViewOptIn).mockResolvedValue(showSession)
+ vi.mocked(shouldPresentNotificationOptIn).mockResolvedValue(showNotifications)
+
+ await expect(loadMobileOnboardingSteps()).resolves.toEqual(expected)
+ expect(shouldPresentSessionViewOptIn).toHaveBeenCalledOnce()
+ expect(shouldPresentNotificationOptIn).toHaveBeenCalledOnce()
+ }
+ )
+
+ it.each([
+ [[], undefined, '/'],
+ [[], 'paired-host', '/h/paired-host'],
+ [
+ ['session-view', 'notifications'],
+ undefined,
+ {
+ pathname: '/mobile-onboarding',
+ params: { steps: 'session-view,notifications' }
+ }
+ ],
+ [
+ ['notifications'],
+ 'paired-host',
+ {
+ pathname: '/mobile-onboarding',
+ params: { steps: 'notifications', hostId: 'paired-host' }
+ }
+ ]
+ ] as const)('maps %j with host %s to the correct destination', (steps, hostId, destination) => {
+ expect(mobileOnboardingDestination(steps, hostId)).toEqual(destination)
+ })
+
+ it('parses route steps in canonical order without duplicates', () => {
+ expect(parseMobileOnboardingSteps('notifications,unknown,session-view,notifications')).toEqual([
+ 'session-view',
+ 'notifications'
+ ])
+ expect(parseMobileOnboardingSteps('notifications')).toEqual(['notifications'])
+ })
+
+ it('defaults missing or invalid route state to the complete wizard', () => {
+ expect(parseMobileOnboardingSteps(undefined)).toEqual(['session-view', 'notifications'])
+ expect(parseMobileOnboardingSteps('unknown')).toEqual(['session-view', 'notifications'])
+ })
+})
diff --git a/mobile/src/onboarding/mobile-onboarding-plan.ts b/mobile/src/onboarding/mobile-onboarding-plan.ts
new file mode 100644
index 000000000..3435034c4
--- /dev/null
+++ b/mobile/src/onboarding/mobile-onboarding-plan.ts
@@ -0,0 +1,51 @@
+import { shouldPresentNotificationOptIn } from '../notifications/notification-opt-in-gate'
+import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate'
+
+export const MOBILE_ONBOARDING_STEPS = ['session-view', 'notifications'] as const
+export type MobileOnboardingStep = (typeof MOBILE_ONBOARDING_STEPS)[number]
+export type MobileOnboardingDestination =
+ | '/'
+ | `/h/${string}`
+ | {
+ pathname: '/mobile-onboarding'
+ params: { steps: string; hostId?: string }
+ }
+
+/** Loads every outstanding decision in the order the wizard presents them. */
+export async function loadMobileOnboardingSteps(): Promise {
+ // Why: the wizard needs the complete plan for accurate progress dots; run the
+ // independent gates together so adding the second decision does not add latency.
+ const [showSessionView, showNotifications] = await Promise.all([
+ shouldPresentSessionViewOptIn(),
+ shouldPresentNotificationOptIn()
+ ])
+ return MOBILE_ONBOARDING_STEPS.filter(
+ (step) =>
+ (step === 'session-view' && showSessionView) ||
+ (step === 'notifications' && showNotifications)
+ )
+}
+
+/** Preserves a paired host while routing through outstanding decisions. */
+export function mobileOnboardingDestination(
+ steps: readonly MobileOnboardingStep[],
+ hostId?: string
+): MobileOnboardingDestination {
+ if (steps.length === 0) {
+ return hostId ? `/h/${hostId}` : '/'
+ }
+ return {
+ pathname: '/mobile-onboarding',
+ params: { steps: steps.join(','), ...(hostId ? { hostId } : {}) }
+ }
+}
+
+/** Restores the canonical order and ignores duplicate or unknown route values. */
+export function parseMobileOnboardingSteps(raw: string | undefined): MobileOnboardingStep[] {
+ if (!raw) {
+ return [...MOBILE_ONBOARDING_STEPS]
+ }
+ const requested = new Set(raw.split(','))
+ const steps = MOBILE_ONBOARDING_STEPS.filter((step) => requested.has(step))
+ return steps.length > 0 ? steps : [...MOBILE_ONBOARDING_STEPS]
+}
diff --git a/mobile/src/onboarding/mobile-onboarding-screen.test.ts b/mobile/src/onboarding/mobile-onboarding-screen.test.ts
new file mode 100644
index 000000000..d571ce28a
--- /dev/null
+++ b/mobile/src/onboarding/mobile-onboarding-screen.test.ts
@@ -0,0 +1,165 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import MobileOnboardingScreen from '../../app/mobile-onboarding'
+
+const mocks = vi.hoisted(() => ({
+ params: { hostId: 'paired-host', steps: 'session-view,notifications' },
+ replace: vi.fn(),
+ reducedMotionEnabled: false,
+ animatedTiming: vi.fn(),
+ ensureNotificationPermissions: vi.fn(),
+ saveDefaultSessionView: vi.fn(),
+ savePushNotificationsEnabled: vi.fn()
+}))
+
+vi.mock('react-native', () => ({
+ AccessibilityInfo: {
+ addEventListener: vi.fn(() => ({ remove: vi.fn() })),
+ isReduceMotionEnabled: vi.fn(() => Promise.resolve(mocks.reducedMotionEnabled))
+ },
+ Animated: {
+ Value: class {},
+ View: 'AnimatedView',
+ multiply: vi.fn(() => 0),
+ timing: mocks.animatedTiming
+ },
+ BackHandler: {
+ addEventListener: vi.fn(() => ({ remove: vi.fn() }))
+ },
+ StyleSheet: { create: (styles: unknown) => styles },
+ Text: 'Text',
+ View: 'View',
+ useWindowDimensions: () => ({ width: 390, height: 844 })
+}))
+
+vi.mock('expo-router', () => ({
+ useFocusEffect: vi.fn(),
+ useLocalSearchParams: () => mocks.params,
+ useRouter: () => ({ replace: mocks.replace })
+}))
+
+vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' }))
+vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'OrcaLogo' }))
+vi.mock('./MobileOnboardingPage', () => ({ MobileOnboardingPage: 'MobileOnboardingPage' }))
+vi.mock('../notifications/mobile-notifications', () => ({
+ ensureNotificationPermissions: mocks.ensureNotificationPermissions
+}))
+vi.mock('../storage/session-view-preferences', () => ({
+ saveDefaultSessionView: mocks.saveDefaultSessionView
+}))
+vi.mock('../storage/preferences', () => ({
+ savePushNotificationsEnabled: mocks.savePushNotificationsEnabled
+}))
+
+describe('MobileOnboardingScreen', () => {
+ let renderer: ReactTestRenderer | null = null
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ mocks.params = { hostId: 'paired-host', steps: 'session-view,notifications' }
+ mocks.replace.mockReset()
+ mocks.reducedMotionEnabled = false
+ mocks.animatedTiming.mockReset().mockReturnValue({
+ start: (callback: (result: { finished: boolean }) => void) => callback({ finished: true })
+ })
+ mocks.ensureNotificationPermissions.mockReset().mockResolvedValue(true)
+ mocks.saveDefaultSessionView.mockReset().mockResolvedValue(undefined)
+ mocks.savePushNotificationsEnabled.mockReset().mockResolvedValue(undefined)
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ vi.restoreAllMocks()
+ })
+
+ async function renderScreen() {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
+ throw new Error(String(args[0]))
+ }
+ })
+ await act(async () => {
+ renderer = create(createElement(MobileOnboardingScreen))
+ })
+ consoleError.mockRestore()
+ }
+
+ function pages() {
+ return renderer!.root.findAllByType('MobileOnboardingPage')
+ }
+
+ it('advances from session view to notifications before opening the paired host', async () => {
+ await renderScreen()
+ expect(pages().map((page) => page.props.active)).toEqual([true, false])
+
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+ expect(mocks.saveDefaultSessionView).toHaveBeenCalledWith('chat')
+ expect(pages().map((page) => page.props.active)).toEqual([false, true])
+ expect(mocks.replace).not.toHaveBeenCalled()
+
+ await act(async () => pages()[1].props.onNotificationChoice('skip'))
+ expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled()
+ expect(mocks.savePushNotificationsEnabled).toHaveBeenCalledWith(false)
+ expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host')
+ })
+
+ it('finishes immediately when the plan contains only one outstanding step', async () => {
+ mocks.params = { hostId: 'paired-host', steps: 'session-view' }
+ await renderScreen()
+
+ await act(async () => pages()[0].props.onSessionChoice('terminal'))
+ expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host')
+ })
+
+ it('keeps the current step retryable when persistence fails', async () => {
+ mocks.params = { hostId: 'paired-host', steps: 'session-view' }
+ mocks.saveDefaultSessionView
+ .mockRejectedValueOnce(new Error('storage unavailable'))
+ .mockResolvedValueOnce(undefined)
+ await renderScreen()
+
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+ expect(pages()[0].props.error).toBe('Your choice could not be saved. Try again.')
+
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+ expect(mocks.saveDefaultSessionView).toHaveBeenCalledTimes(2)
+ expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host')
+ })
+
+ it('resets carousel state when the route supplies a new onboarding plan', async () => {
+ await renderScreen()
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+ expect(pages().map((page) => page.props.active)).toEqual([false, true])
+
+ mocks.params = { hostId: 'paired-host', steps: 'notifications' }
+ await act(async () => renderer!.update(createElement(MobileOnboardingScreen)))
+
+ expect(pages()).toHaveLength(1)
+ expect(pages()[0].props).toMatchObject({ step: 'notifications', active: true })
+ })
+
+ it('skips the slide animation when the device requests reduced motion', async () => {
+ mocks.reducedMotionEnabled = true
+ await renderScreen()
+
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+
+ expect(mocks.animatedTiming).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ duration: 0, useNativeDriver: true })
+ )
+ })
+
+ it('keeps the next decision available if the cosmetic transition is interrupted', async () => {
+ mocks.animatedTiming.mockReturnValue({
+ start: (callback: (result: { finished: boolean }) => void) => callback({ finished: false })
+ })
+ await renderScreen()
+
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+
+ expect(pages()[1].props).toMatchObject({ active: true, busyChoice: null })
+ })
+})
diff --git a/mobile/src/onboarding/mobile-onboarding-styles.ts b/mobile/src/onboarding/mobile-onboarding-styles.ts
new file mode 100644
index 000000000..20f36ec0f
--- /dev/null
+++ b/mobile/src/onboarding/mobile-onboarding-styles.ts
@@ -0,0 +1,132 @@
+import { StyleSheet } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export const mobileOnboardingStyles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bgBase
+ },
+ brandRow: {
+ minHeight: 52,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ paddingHorizontal: spacing.xl
+ },
+ brandName: {
+ color: colors.textPrimary,
+ fontSize: 17,
+ fontWeight: '700'
+ },
+ progress: {
+ position: 'absolute',
+ left: '50%',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 7,
+ transform: [{ translateX: -18 }]
+ },
+ progressDot: {
+ width: 7,
+ height: 7,
+ borderRadius: 999,
+ backgroundColor: colors.borderSubtle
+ },
+ progressDotActive: {
+ width: 22,
+ backgroundColor: colors.textPrimary
+ },
+ carouselViewport: {
+ flex: 1,
+ overflow: 'hidden'
+ },
+ carouselTrack: {
+ height: '100%',
+ flexDirection: 'row'
+ },
+ page: {
+ height: '100%'
+ },
+ // Why: every decision remains reachable in landscape and with accessibility
+ // text scaling even though Back and swipe-to-skip are intentionally disabled.
+ pageContent: {
+ flexGrow: 1,
+ paddingHorizontal: spacing.xl
+ },
+ content: {
+ flexGrow: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: spacing.xl
+ },
+ iconSurface: {
+ width: 64,
+ height: 64,
+ borderRadius: radii.card,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.bgRaised,
+ marginBottom: spacing.xl
+ },
+ title: {
+ maxWidth: 420,
+ color: colors.textPrimary,
+ fontSize: 26,
+ fontWeight: '700',
+ letterSpacing: -0.3,
+ textAlign: 'center'
+ },
+ body: {
+ maxWidth: 420,
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ lineHeight: 21,
+ textAlign: 'center',
+ marginTop: spacing.md
+ },
+ footer: {
+ width: '100%',
+ maxWidth: 420,
+ alignSelf: 'center',
+ paddingBottom: spacing.lg
+ },
+ primaryButton: {
+ minHeight: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ backgroundColor: colors.surfaceBright,
+ paddingVertical: spacing.sm
+ },
+ primaryButtonText: {
+ color: colors.bgBase,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ },
+ secondaryButton: {
+ minHeight: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ marginTop: spacing.xs,
+ paddingVertical: spacing.sm
+ },
+ secondaryButtonText: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ fontWeight: '500'
+ },
+ buttonPressed: {
+ opacity: 0.72
+ },
+ buttonDisabled: {
+ opacity: 0.58
+ },
+ error: {
+ color: colors.statusRed,
+ fontSize: typography.metaSize,
+ lineHeight: 18,
+ textAlign: 'center',
+ marginBottom: spacing.sm
+ }
+})
diff --git a/mobile/src/session/session-view-opt-in-gate.test.ts b/mobile/src/session/session-view-opt-in-gate.test.ts
new file mode 100644
index 000000000..8a31f466e
--- /dev/null
+++ b/mobile/src/session/session-view-opt-in-gate.test.ts
@@ -0,0 +1,49 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { readDefaultSessionViewPreference } from '../storage/session-view-preferences'
+import { shouldPresentSessionViewOptIn } from './session-view-opt-in-gate'
+
+vi.mock('../storage/session-view-preferences', () => ({
+ readDefaultSessionViewPreference: vi.fn()
+}))
+
+describe('session view opt-in gate', () => {
+ beforeEach(() => {
+ vi.mocked(readDefaultSessionViewPreference).mockReset()
+ })
+
+ it('presents when no default has ever been saved', async () => {
+ vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({
+ value: null,
+ loaded: true,
+ hasStoredValue: false
+ })
+ await expect(shouldPresentSessionViewOptIn()).resolves.toBe(true)
+ })
+
+ it.each(['terminal', 'chat'] as const)('preserves an existing %s choice', async (value) => {
+ vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({
+ value,
+ loaded: true,
+ hasStoredValue: true
+ })
+ await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false)
+ })
+
+ it('does not overwrite an unknown stored value', async () => {
+ vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({
+ value: null,
+ loaded: true,
+ hasStoredValue: true
+ })
+ await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false)
+ })
+
+ it('does not block startup when storage is unreadable', async () => {
+ vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({
+ value: null,
+ loaded: false,
+ hasStoredValue: false
+ })
+ await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false)
+ })
+})
diff --git a/mobile/src/session/session-view-opt-in-gate.ts b/mobile/src/session/session-view-opt-in-gate.ts
new file mode 100644
index 000000000..b7e6cb1a0
--- /dev/null
+++ b/mobile/src/session/session-view-opt-in-gate.ts
@@ -0,0 +1,10 @@
+import { readDefaultSessionViewPreference } from '../storage/session-view-preferences'
+
+/** Whether to show the one-time screen that lets the user pick terminal vs native
+ * chat. Present it only when the read succeeded and no default was ever saved, so
+ * people who already chose (in the opt-in or Settings) are never prompted again,
+ * and a transient storage failure doesn't trap startup behind the screen. */
+export async function shouldPresentSessionViewOptIn(): Promise {
+ const preference = await readDefaultSessionViewPreference()
+ return preference.loaded && !preference.hasStoredValue
+}
diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts
index 13e43d45b..b636ea12d 100644
--- a/mobile/src/storage/preferences.test.ts
+++ b/mobile/src/storage/preferences.test.ts
@@ -24,6 +24,7 @@ import {
import {
loadDefaultSessionView,
loadSessionViewOverrides,
+ readDefaultSessionViewPreference,
readSessionViewOverridesPreference,
saveDefaultSessionView,
updateSessionViewOverride
@@ -62,6 +63,39 @@ describe('session view preference', () => {
await expect(loadDefaultSessionView()).resolves.toBe('terminal')
})
+ it('reports an absent default as an undecided (null) preference', async () => {
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
+ await expect(readDefaultSessionViewPreference()).resolves.toEqual({
+ value: null,
+ loaded: true,
+ hasStoredValue: false
+ })
+
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue('chat')
+ await expect(readDefaultSessionViewPreference()).resolves.toEqual({
+ value: 'chat',
+ loaded: true,
+ hasStoredValue: true
+ })
+
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue('bogus')
+ await expect(readDefaultSessionViewPreference()).resolves.toEqual({
+ value: null,
+ loaded: true,
+ hasStoredValue: true
+ })
+ })
+
+ it('marks an unreadable default as not loaded so the opt-in gate can bail', async () => {
+ vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
+ await expect(readDefaultSessionViewPreference()).resolves.toEqual({
+ value: null,
+ loaded: false,
+ hasStoredValue: false
+ })
+ await expect(loadDefaultSessionView()).resolves.toBe('terminal')
+ })
+
it('loads and updates per-tab overrides under a host-and-worktree scoped key', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'terminal', 'tab-3': 'bogus' })
diff --git a/mobile/src/storage/session-view-preferences.ts b/mobile/src/storage/session-view-preferences.ts
index 750167e3d..cd9597d6f 100644
--- a/mobile/src/storage/session-view-preferences.ts
+++ b/mobile/src/storage/session-view-preferences.ts
@@ -22,17 +22,32 @@ function clearDefaultViewWriteBarrier(barrier: Promise): void {
}
}
-/** Global (per-device) default for how supported agent sessions open. */
-export async function loadDefaultSessionView(): Promise {
+export type DefaultSessionViewPreference = {
+ readonly value: MobileSessionView | null
+ readonly loaded: boolean
+ readonly hasStoredValue: boolean
+}
+
+/** Reads the raw per-device default and whether its storage key exists. */
+export async function readDefaultSessionViewPreference(): Promise {
await defaultViewWriteBarrier
try {
const raw = await AsyncStorage.getItem(DEFAULT_SESSION_VIEW_KEY)
- return raw === 'chat' || raw === 'terminal' ? raw : DEFAULT_SESSION_VIEW
+ return {
+ value: raw === 'chat' || raw === 'terminal' ? raw : null,
+ loaded: true,
+ hasStoredValue: raw !== null
+ }
} catch {
- return DEFAULT_SESSION_VIEW
+ return { value: null, loaded: false, hasStoredValue: false }
}
}
+/** Global (per-device) default for how supported agent sessions open. */
+export async function loadDefaultSessionView(): Promise {
+ return (await readDefaultSessionViewPreference()).value ?? DEFAULT_SESSION_VIEW
+}
+
export function saveDefaultSessionView(view: MobileSessionView): Promise {
// Why: callers can outlive their route; a shared barrier keeps remounted
// Settings screens from letting an older write land after a newer choice.