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
This commit is contained in:
Brennan Benson 2026-07-20 11:11:23 -07:00 committed by GitHub
parent d9d939a33b
commit ccd72f5909
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1054 additions and 262 deletions

View File

@ -179,7 +179,7 @@ export default function RootLayout() {
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen
name="notification-opt-in"
name="mobile-onboarding"
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />

View File

@ -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) => {

View File

@ -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 (
<MobileOnboardingFlow
key={`${hostId ?? 'home'}:${rawSteps ?? 'all'}`}
hostId={hostId}
rawSteps={rawSteps}
/>
)
}
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<MobileOnboardingBusyChoice>(null)
const [error, setError] = useState<string | null>(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 (
<SafeAreaView style={styles.container}>
<View style={styles.brandRow}>
<OrcaLogo size={22} />
<Text style={styles.brandName}>Orca</Text>
{steps.length > 1 ? (
<View
accessible
accessibilityRole="progressbar"
accessibilityLabel="Onboarding progress"
accessibilityValue={{ text: `Step ${activeIndex + 1} of ${steps.length}` }}
style={styles.progress}
>
{steps.map((step, index) => (
<View
key={step}
style={[styles.progressDot, index === activeIndex && styles.progressDotActive]}
/>
))}
</View>
) : null}
</View>
<View style={styles.carouselViewport}>
<Animated.View
style={[
styles.carouselTrack,
{ width: width * steps.length, transform: [{ translateX }] }
]}
>
{steps.map((step, index) => (
<MobileOnboardingPage
key={step}
step={step}
width={width}
active={index === activeIndex}
busyChoice={busyChoice}
error={error}
onSessionChoice={(view) => void chooseSessionView(view)}
onNotificationChoice={(choice) => void chooseNotifications(choice)}
/>
))}
</Animated.View>
</View>
</SafeAreaView>
)
}
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
}

View File

@ -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<string | null>(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 (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.brandRow}>
<OrcaLogo size={22} />
<Text style={styles.brandName}>Orca</Text>
</View>
<View style={styles.content}>
<View style={styles.iconSurface}>
<BellRing size={30} color={colors.textPrimary} />
</View>
<Text style={styles.eyebrow}>Notifications</Text>
<Text style={styles.title}>Stay updated while away</Text>
<Text style={styles.body}>
Get notified on this device when an agent needs your input or finishes a task.
</Text>
</View>
<View style={styles.footer}>
{error ? (
<Text style={styles.error} accessibilityRole="alert">
{error}
</Text>
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel="Enable agent notifications"
disabled={busyChoice !== null}
style={({ pressed }) => [
styles.primaryButton,
pressed && styles.buttonPressed,
busyChoice !== null && styles.buttonDisabled
]}
onPress={() => void choose('enable')}
>
{busyChoice === 'enable' ? (
<ActivityIndicator color={colors.bgBase} />
) : (
<Text style={styles.primaryButtonText}>Enable notifications</Text>
)}
</Pressable>
<Pressable
accessibilityRole="button"
disabled={busyChoice !== null}
style={({ pressed }) => [
styles.secondaryButton,
pressed && styles.buttonPressed,
busyChoice !== null && styles.buttonDisabled
]}
onPress={() => void choose('skip')}
>
{busyChoice === 'skip' ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Text style={styles.secondaryButtonText}>Not now</Text>
)}
</Pressable>
<Text style={styles.footerNote}>You can change this any time in Settings.</Text>
</View>
</ScrollView>
</SafeAreaView>
)
}
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
}
})

View File

@ -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

View File

@ -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

View File

@ -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')
})
})

View File

@ -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 (
<ScrollView
style={[styles.page, { width }]}
contentContainerStyle={styles.pageContent}
showsVerticalScrollIndicator={false}
accessibilityElementsHidden={!active}
importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
>
<View style={styles.content}>
<View style={styles.iconSurface}>
{isSessionView ? (
<MessageSquare size={30} color={colors.textPrimary} />
) : (
<BellRing size={30} color={colors.textPrimary} />
)}
</View>
<Text style={styles.title}>
{isSessionView ? 'How should sessions open?' : 'Stay updated while away'}
</Text>
<Text style={styles.body}>
{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.'}
</Text>
</View>
<View style={styles.footer}>
{error ? (
<Text style={styles.error} accessibilityRole="alert">
{error}
</Text>
) : null}
{isSessionView ? (
<SessionViewChoices busyChoice={busyChoice} disabled={busy} onChoice={onSessionChoice} />
) : (
<NotificationChoices
busyChoice={busyChoice}
disabled={busy}
onChoice={onNotificationChoice}
/>
)}
</View>
</ScrollView>
)
}
function SessionViewChoices({
busyChoice,
disabled,
onChoice
}: {
busyChoice: MobileOnboardingBusyChoice
disabled: boolean
onChoice: (view: MobileSessionView) => void
}) {
return (
<>
<ChoiceButton
label="Use native chat"
accessibilityLabel="Open sessions in native chat"
primary
busy={busyChoice === 'chat'}
disabled={disabled}
onPress={() => onChoice('chat')}
/>
<ChoiceButton
label="Keep terminal"
accessibilityLabel="Open sessions in the terminal"
busy={busyChoice === 'terminal'}
disabled={disabled}
onPress={() => onChoice('terminal')}
/>
</>
)
}
function NotificationChoices({
busyChoice,
disabled,
onChoice
}: {
busyChoice: MobileOnboardingBusyChoice
disabled: boolean
onChoice: (choice: NotificationOnboardingChoice) => void
}) {
return (
<>
<ChoiceButton
label="Enable notifications"
accessibilityLabel="Enable agent notifications"
primary
busy={busyChoice === 'enable'}
disabled={disabled}
onPress={() => onChoice('enable')}
/>
<ChoiceButton
label="Not now"
accessibilityLabel="Skip notifications for now"
busy={busyChoice === 'skip'}
disabled={disabled}
onPress={() => onChoice('skip')}
/>
</>
)
}
function ChoiceButton({
label,
accessibilityLabel,
primary = false,
busy,
disabled,
onPress
}: {
label: string
accessibilityLabel?: string
primary?: boolean
busy: boolean
disabled: boolean
onPress: () => void
}) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
disabled={disabled}
style={({ pressed }) => [
primary ? styles.primaryButton : styles.secondaryButton,
pressed && styles.buttonPressed,
disabled && styles.buttonDisabled
]}
onPress={onPress}
>
{busy ? (
<ActivityIndicator color={primary ? colors.bgBase : colors.textSecondary} />
) : (
<Text style={primary ? styles.primaryButtonText : styles.secondaryButtonText}>{label}</Text>
)}
</Pressable>
)
}

View File

@ -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'])
})
})

View File

@ -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<MobileOnboardingStep[]> {
// 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]
}

View File

@ -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 })
})
})

View File

@ -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
}
})

View File

@ -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)
})
})

View File

@ -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<boolean> {
const preference = await readDefaultSessionViewPreference()
return preference.loaded && !preference.hasStoredValue
}

View File

@ -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' })

View File

@ -22,17 +22,32 @@ function clearDefaultViewWriteBarrier(barrier: Promise<void>): void {
}
}
/** Global (per-device) default for how supported agent sessions open. */
export async function loadDefaultSessionView(): Promise<MobileSessionView> {
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<DefaultSessionViewPreference> {
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<MobileSessionView> {
return (await readDefaultSessionViewPreference()).value ?? DEFAULT_SESSION_VIEW
}
export function saveDefaultSessionView(view: MobileSessionView): Promise<void> {
// Why: callers can outlive their route; a shared barrier keeps remounted
// Settings screens from letting an older write land after a newer choice.