iOS: first-run onboarding before pairing - #5655
Conversation
Add a one-time, skippable onboarding flow shown post-auth, in front of the never-paired add-device state. Three pages: what cmux iOS is (your Mac's terminals + agents on your phone), what it needs (the cmux Mac app running and Tailscale on both devices, with a "Set up Tailscale" link and why the private tailnet path is used), and pair (hand off to the existing QR/manual pairing flow). No new pairing code: on complete the root view marks the flag seen and falls through to DisconnectedWorkspaceShellView, which already auto-presents PairingView. Gate (pure, in CmuxMobileWorkspace next to MobileRootAuthGate): shouldShowOnboarding(hasSeenOnboarding:hasKnownPairedMac:) = !seen && !paired. Placed after the stored-Mac reconnect-determining branch so hasKnownPairedMac is authoritative, so a returning paired-but-offline user (reachable after a failed reconnect with seen still false) is excluded. Seen flag: MobileOnboardingStore (value type, injected UserDefaults), mirroring MobileDisplaySettings; built once at the composition root and threaded down. markSeen() is called in the button action, not a view-lifecycle callback. forceSeen bypass is set for the UI-test mock harness and dogfood auto-pair attach URLs so neither path is wedged behind onboarding; it never writes the real install's flag. Settings gains a "How Pairing Works" row that replays the explainer without touching the seen flag. All copy localized (en + ja). Swift Testing unit tests for the gate truth table and the store round-trip / forceSeen no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughImplements an iOS first-run onboarding flow: a persisted "seen" store with test bypass, a pure gating function, paged onboarding UI and pages, root-level routing and app wiring, settings entry point, and localized strings. ChangesiOS First-Run Onboarding Flow
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 21✅ Passed checks (21 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryAdds a one-time, skippable, three-page first-run onboarding flow to the iOS companion app. It gates after authentication and before the never-paired add-device state, using a pure
Confidence Score: 5/5Safe to merge. All changes are additive iOS-only UI; no pairing, auth, or persistence paths are modified. The gate logic is a pure two-input boolean covered by an exhaustive truth-table test. The seen flag is seeded from UserDefaults at init time (avoiding flash-of-wrong-screen), written only in the button action, and never touched by the forceSeen bypass path. Returning paired-but-offline users are correctly excluded by the paired check. Localization covers the only two locales in the catalog. No blocking primitives, actor isolation mistakes, or production logging issues were found. No files require special attention. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[App Launch] --> B{isAuthenticated?}
B -- No --> C[AuthView]
B -- Yes --> D{pairedMacHint?}
D -- Undetermined --> E[MobilePairedMacDeterminingView\nresolves hasKnownPairedMac]
E --> F{shouldShowOnboarding?\n!seen AND !paired}
D -- Known --> F
F -- true --> G[OnboardingFlowView\nSkip / Next / Get Started]
G -- markSeen + complete --> H[DisconnectedWorkspaceShellView\nauto-presents PairingView]
F -- false --> I{connectionState == .connected?}
I -- No --> H
I -- Yes --> J[Connected workspace UI]
K[Settings] --> L[How Pairing Works sheet]
L --> G2[OnboardingFlowView\nonComplete = dismiss only]
G2 -- dismiss --> K
Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile |
| footer | ||
| } | ||
| .background(PlatformPalette.systemBackground.ignoresSafeArea()) | ||
| .interactiveDismissDisabled() |
There was a problem hiding this comment.
interactiveDismissDisabled blocks swipe-to-dismiss in the Settings re-entry sheet
.interactiveDismissDisabled() is unconditional, so it applies to both the first-run full-screen presentation and the Settings "How Pairing Works" sheet in MobileSettingsView. A user who opens the sheet from Settings cannot swipe it away — they must tap Skip or march through all three pages, which is an unusual constraint for an informational replay. For the first-run case blocking dismiss makes sense; for Settings re-entry it is a UX regression over standard iOS sheet behavior. Consider passing a flag or checking the presentation context (e.g. an isDismissible parameter) so the first-run path keeps the guard while the Settings path allows a free swipe-down.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| .onAppear { | ||
| analytics.capture("ios_onboarding_viewed", ["page": .int(0)]) | ||
| } | ||
| .onChange(of: pageIndex) { _, newValue in | ||
| analytics.capture("ios_onboarding_viewed", ["page": .int(newValue)]) |
There was a problem hiding this comment.
Analytics events are context-blind between first-run and Settings re-entry
ios_onboarding_viewed, ios_onboarding_skipped, and ios_onboarding_completed fire identically whether the flow is the first-run gate or the "How Pairing Works" Settings re-entry. Any funnel analysis that tracks page views or completion rates will double-count against the first-run cohort for every Settings tap. Consider passing a source string (e.g. "first_run" vs "settings") and including it as an event property.
# Conflicts: # ios/cmux/AppCompositionRoot.swift # ios/cmux/cmuxApp.swift # ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b5f7ccf3e9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return MobileOnboardingGate.shouldShowOnboarding( | ||
| hasSeenOnboarding: hasSeenOnboarding, | ||
| hasKnownPairedMac: store.hasKnownPairedMac |
There was a problem hiding this comment.
Don't gate onboarding solely on the persisted paired hint
When a returning install has an active paired Mac record but none of its saved routes are supported by the current runtime, reconnectActiveMacIfAvailable finds the Mac and then deliberately writes hasKnownPairedMac = false before falling through (MobileShellComposite.swift:932-940). Because the new gate here treats that false hint as a genuine first run, those already-paired users see first-run onboarding instead of the disconnected/add-device recovery path, which breaks the intended paired-but-offline exclusion for this route-migration/unsupported-route case.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift (1)
102-110:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winMove "How Pairing Works" button inside the Connection section.
The button currently sits outside the
Section(but inside thehasConnectionSectionconditional), so it renders as a standalone row in the Form. All other buttons in this file are placed inside their respective sections (Account, Terminal, Notifications). Move the button inside the Connection section's closing brace (after line 101, before the current}on line 101) to group it visually with the other connection-related rows.📐 Proposed fix: move button inside the Section
} .accessibilityIdentifier("MobileSettingsRescanQR") } + Button { + showingOnboarding = true + } label: { + Label( + L10n.string("mobile.settings.howPairingWorks", defaultValue: "How Pairing Works"), + systemImage: "questionmark.circle" + ) + } + .accessibilityIdentifier("MobileSettingsHowPairingWorks") } - Button { - showingOnboarding = true - } label: { - Label( - L10n.string("mobile.settings.howPairingWorks", defaultValue: "How Pairing Works"), - systemImage: "questionmark.circle" - ) - } - .accessibilityIdentifier("MobileSettingsHowPairingWorks") }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift` around lines 102 - 110, The "How Pairing Works" Button in MobileSettingsView is currently outside the Connection Section (but still inside the hasConnectionSection conditional), causing it to render as a standalone Form row; move the Button (the block that sets showingOnboarding = true and uses Label with L10n.string("mobile.settings.howPairingWorks")) so it is inside the Connection Section's curly braces (i.e., place it before the Section's closing brace within the hasConnectionSection branch) so it renders as a row grouped with other connection-related items.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift`:
- Around line 102-110: The "How Pairing Works" Button in MobileSettingsView is
currently outside the Connection Section (but still inside the
hasConnectionSection conditional), causing it to render as a standalone Form
row; move the Button (the block that sets showingOnboarding = true and uses
Label with L10n.string("mobile.settings.howPairingWorks")) so it is inside the
Connection Section's curly braces (i.e., place it before the Section's closing
brace within the hasConnectionSection branch) so it renders as a row grouped
with other connection-related items.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0297cb73-aeee-4d68-9236-591aa94c8829
📒 Files selected for processing (3)
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swiftios/cmux/Resources/Localizable.xcstrings
# Conflicts: # ios/cmux/Resources/Localizable.xcstrings
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 408ea6b. Configure here.
| // sees the one-time explainer before the add-device flow; a returning | ||
| // paired-but-offline user (who can reach here after a failed | ||
| // reconnect) is excluded by the gate and falls through to pairing. | ||
| onboardingFlow |
There was a problem hiding this comment.
Onboarding shows while connected
Medium Severity
The onboarding branch runs before the connected workspace branch and is not gated on connectionState. If the shell reaches .connected while hasKnownPairedMac is still false, first-run onboarding replaces the live workspace until the user skips or finishes it.
Reviewed by Cursor Bugbot for commit 408ea6b. Configure here.


First-run onboarding for the cmux iOS companion app: a one-time, skippable, three-page explainer shown before the user pairs a Mac.
Pages: what cmux iOS is (your Mac's terminals and AI coding agents on your phone), what it needs (the cmux Mac app running, plus Tailscale on both devices, with a "Set up Tailscale" link to https://tailscale.com/download and a short note on why the private tailnet path is used instead of a cloud relay), and pair (hand off to the existing QR/manual pairing flow). All copy is short and concrete, no marketing fluff, no em dashes.
No new pairing code. On complete the root view marks the seen flag and falls through to
DisconnectedWorkspaceShellView, which already auto-presentsPairingView.Placement (and why post-auth): onboarding gates after authentication, in front of the never-paired add-device state, so
store.hasKnownPairedMacis authoritative when the gate reads it. This is what satisfies the never-onboarded-vs-paired-but-offline requirement: a returning, previously-paired user whose Stack session expired (and whose seen flag is still false because they updated across the flag's introduction) presents an ambiguouspairedMacHintUndeterminedhint pre-auth, but post-auth the reconnect-determining branch resolves the real paired-Mac record, so the gate seeshasKnownPairedMac == trueand skips onboarding instead of interrupting them. The gate is a pure function next toMobileRootAuthGate:shouldShowOnboarding(hasSeenOnboarding:hasKnownPairedMac:) = !seen && !paired.Seen flag:
MobileOnboardingStore(value type, injectedUserDefaults), mirroringMobileDisplaySettings; built once at the composition root and threaded down.markSeen()is called in the button action, not a view-lifecycle callback. AforceSeenbypass is set for the UI-test mock harness and dogfood auto-pair attach URLs so neither path is wedged behind onboarding; it never writes the real install's flag.Settings gains a "How Pairing Works" row that replays the explainer without touching the seen flag.
Localization: 11 new keys, en + ja, in
ios/cmux/Resources/Localizable.xcstrings.Tests (Swift Testing): the gate truth table (the paired-but-offline exclusion and the never-onboarded show) in
CmuxMobileWorkspace, and the store round-trip /forceSeenno-op inCmuxMobileShellModel.Verification: iOS simulator Debug and generic-device Release both compile (Release build catches DEBUG-gating leaks; none). autoreview (codex) clean; cmux Aziz policy clean. Not yet dogfooded on device (folds into a dog round).
Design doc:
plans/feat-ios-onboarding/DESIGN.mdin cmuxterm-hq.🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
Medium Risk
Reorders the mobile root scene after authentication; mitigated by pure gating, paired-Mac exclusion, and test/UI bypasses, with no pairing or auth protocol changes.
Overview
Adds iOS first-run onboarding: a skippable three-page explainer (what cmux is, Tailscale/private link, how to pair) shown after sign-in and before the never-paired add-device flow, without changing pairing logic.
Persistence and gating:
MobileOnboardingStoresaves a one-time “seen” flag in injectedUserDefaults, withforceSeenfor UI-test mock data and dogfood/attach URLs so those launches skip onboarding without writing the real flag.MobileOnboardingGate.shouldShowOnboardingis!seen && !hasKnownPairedMac, evaluated only after the stored-Mac reconnect/determining branch so returning paired-but-offline users are not blocked. The root view seedshasSeenOnboardingat init to avoid flashing the wrong screen.UI and wiring: New
OnboardingFlowView(paged flow, analytics, Tailscale download link) is injected fromAppCompositionRoot→CMUXMobileRootScene→CMUXMobileAppView. Completing onboarding callsmarkSeen()and falls through to the existing disconnected shell / auto-presentedPairingView. Settings adds How Pairing Works to replay the flow in a sheet without updating the seen flag.Tests and strings: Unit tests cover the gate truth table and store persistence/
forceSeen; new en/ja localization keys for onboarding and settings.Reviewed by Cursor Bugbot for commit 408ea6b. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Adds a one-time, skippable onboarding flow on iOS shown after sign-in and before pairing, with a persisted seen flag and a paired-state check to avoid interrupting returning users. On completion it falls through to the existing pairing flow; no pairing logic changes.
MobileOnboardingGate.shouldShowOnboarding(!seen && !paired), placed after the reconnect-determining branch;CMUXMobileRootViewseeds the seen state at init to avoid flashes.MobileOnboardingStore(UserDefaults) with aforceSeenbypass for UI tests and dogfood/attach URLs; bypass never writes the real flag.AppCompositionRootintoCMUXMobileRootScene→CMUXMobileAppView→CMUXMobileRootView.Written for commit 408ea6b. Summary will update on new commits.
Summary by CodeRabbit
New Features
Tests
Localization