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:
parent
d9d939a33b
commit
ccd72f5909
|
|
@ -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 }} />
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue