Skip to content

iOS Devices sheet: always-available Add device entry that re-enters the pairing flow#6017

Open
lawrencecchen wants to merge 5 commits into
mainfrom
feat-devices-add-device
Open

iOS Devices sheet: always-available Add device entry that re-enters the pairing flow#6017
lawrencecchen wants to merge 5 commits into
mainfrom
feat-devices-add-device

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Lawrence: "in the devices menu we should also be able to add a new device and go through the flow again."

Which side

The "devices menu" is the iOS Devices sheet (DeviceTreeView, titled "Devices", opened from the workspace list toolbar). Once a phone was paired, the add-device pairing flow became unreachable from there: the root PairingView sheet only mounts on the disconnected branch, and the only connected-state path was Settings -> Switch Mac, which offers a bare QR scanner, not the full flow.

The Mac side already has two re-entry points into its pairing window (Settings -> Mobile -> "Pair…" and the command palette "Connect iPhone/iPad") and has no Devices menu to extend, so no Mac changes here. A dedicated Mac menu item is a possible follow-up; left out to avoid colliding with the in-flight pairing-window changes.

Shared action and entry points

One shared beginAddDevice() action in DeviceTreeView, invoked from two entry points per the shared-behavior policy:

  • an always-visible "Add device" list row (also present in the empty state), MobileDeviceTreeAddDevice
  • a toolbar plus button, MobileDeviceTreeAddDeviceToolbar

Both present the existing first-run PairingView (QR scan or manual host) as a sheet over the tree, driven by the same store mutations as the root pairing path (connectPairingInput / connectManualHost). No keyboard shortcut added. Pairing window internals (MobilePairingView/Model/WindowController, iOS URL scheme) are untouched.

Re-entering the flow over a live connection needed explicit policy, extracted into DeviceTreeAddDevicePolicy (a documented public value type in CmuxMobileShellModel, next to MobileConnectionState):

  • Cancelling a freshly opened sheet must not call cancelPairing() while still connected; that call sets connectionState = .disconnected and would tear down the live connection.
  • The sheet dismisses only when the attempt left the shell connected (success), then refreshes loadPairedMacs + loadRegistryDevices so the new device appears in the tree.
  • A cancelled attempt (disconnected, no connectionError — the store's cancellation path sets none) restores the previously live Mac via the existing switchToMac(macDeviceID:), so backing out of the sheet never strands the user.
  • A failed attempt (error set) deliberately does not auto-restore: the reconnect path begins a fresh pairing attempt, which clears connectionError and would erase the failure reason while the user reads it on the auto-presented pairing sheet.

Review notes (autoreview, 3 rounds)

Fixed from review: the policy is a value type with an initializer (was a namespace enum), all public API has DocC, and the cancel-restore behavior above came out of round 1. cmux-policy-check is clean.

Consciously deferred (one root cause, store-owned): MobileShellComposite's pairing attempt is destructive — it can replace the live client before success, any new attempt clears the store-owned connectionError, and there is no way to invalidate an in-flight attempt without clearing the live context. Consequences the reviewer flagged: a genuinely failed add attempt still drops the live session (error stays readable, recovery is Settings -> Switch Mac), and a cancel during a mid-flight attempt cannot guarantee the attempt stops. Both are pre-existing in the shipped Settings -> Switch Mac -> "Pair Another Mac" path, which calls the same destructive connectPairingURL while connected with no restore and no cancellation at all; this PR's entry point is strictly safer than that path. The real fix is an attempt-scoped pairing API in the store (do not tear down the live client until the new connection succeeds; non-destructive cancel; failure reporting that survives a reconnect) — follow-up, and it intersects the concurrent pairing-flow work. Malformed input never reaches the destructive path: PairingView validates manual hosts locally and the QR scanner accepts only cmux-ios:// pairing links.

Tests

DeviceTreeAddDevicePolicyTests (8 tests) in CmuxMobileShellModelTests; swift test --package-path Packages/CmuxMobileShellModel green (38 tests, 7 suites). The policy lives in the model package because CmuxMobileShellUI declares iOS-only platforms and cannot host-run swift test.

Builds verified: iOS simulator arm64 (ios/cmux.xcworkspace, scheme cmux-ios) and macOS (cmux.xcodeproj, scheme cmux, build-only; the Mac app is behaviorally unchanged).

Localization audit

No new user-facing strings. The row and the toolbar button's accessibility label reuse mobile.addDevice.title ("Add device"); mobile.addDevice.title, mobile.common.done, mobile.deviceTree.title verified present with en + ja in ios/cmux/Resources/Localizable.xcstrings. Accessibility identifiers are not user-facing.

Dogfood

  1. Pair the iPhone/simulator (ios/scripts/reload.sh --tag adddev), open the workspace list, tap the Devices toolbar icon.
  2. Previously bad: the Devices sheet had no way to add a device. Now: an "Add device" row sits below the tree and a plus button sits in the toolbar; either opens the full Add device flow (QR scan or manual host).
  3. Cancel the sheet without starting an attempt: the live connection must survive.
  4. Pair a second Mac through it: on success the sheet closes and the new Mac appears in the tree.

🤖 Generated with Claude Code


Note

Medium Risk
Changes mobile connection lifecycle around destructive pairing while connected; mitigated by explicit policy, restore via existing switchToMac, and unit tests, with known pre-existing limits on mid-flight cancel documented in the PR.

Overview
The iOS Devices sheet (DeviceTreeView) now exposes Add device at all times—a list row (including empty state) and a toolbar +—both driving one beginAddDevice() path that presents the existing PairingView sheet over the tree.

A new DeviceTreeAddDevicePolicy in the model package encodes behavior when pairing re-enters over a live Mac session: Cancel must not call cancelPairing() while still connected; the sheet dismisses and refreshes the device list only on success; a cancelled attempt (disconnected, no connectionError) restores the prior Mac via switchToMac; failed attempts keep the sheet and error visible and do not auto-restore (reconnect would clear the error).

DeviceTreeAddDevicePolicyTests (8 cases) lock in those rules for swift test on the model package.

Reviewed by Cursor Bugbot for commit 48ec656. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Add-device entry in the device tree (toolbar button and list row) opens a pairing sheet.
    • Pairing flow: safer cancel behavior that preserves live connections, restores previous Mac when appropriate, and refreshes paired/registry devices after successful pairing.
    • Accessibility identifier added to the device tree view.
  • Tests

    • Tests covering cancel/dismiss/restore behavior across connection states.

The Devices sheet previously only listed devices; once paired, the
add-device pairing flow was unreachable except by digging into
Settings -> Switch Mac (scanner only) or losing the connection. Add a
shared beginAddDevice action exposed through two entry points (a list
row and a toolbar plus) that re-enters the full first-run PairingView
flow (QR scan or manual host) as a sheet over the tree.

Cancel and dismissal semantics live in a pure, unit-tested
DeviceTreeAddDevicePolicy in CmuxMobileShellModel: cancelling over a
live connection must not call cancelPairing() (which would tear the
connection down), and the sheet dismisses only when the attempt left
the shell connected, refreshing the tree so the new device appears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Canceled Canceled Jun 12, 2026 11:40pm
cmux-staging Building Building Preview, Comment Jun 12, 2026 11:40pm

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds DeviceTreeAddDevicePolicy (three predicate methods), unit tests for its cancel/dismiss/restore semantics, and an always-available add-device sheet in DeviceTreeView that uses the policy to decide cancel, dismiss, refresh, or restore flows.

Changes

Device pairing policy and tests

Layer / File(s) Summary
Policy contract and predicates
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/DeviceTreeAddDevicePolicy.swift
Adds DeviceTreeAddDevicePolicy struct with cancelResetsPairingState, dismissesAfterPairingAttempt, and restoresPreviousConnection predicates.
Policy unit tests
Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/DeviceTreeAddDevicePolicyTests.swift
Adds tests validating cancel, dismiss-on-success, and previous-MAC restore semantics across connection states and error conditions.

Device tree add-device flow integration

Layer / File(s) Summary
State and entry points
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift
Adds @State isShowingAddDevice, a toolbar plus button, and an Add device list row that call beginAddDevice(), plus an accessibility identifier.
Sheet presentation and pairing handlers
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift
Presents PairingView in a .sheet; connects pairing callbacks to the shell store and finishAddDevice(previousMacDeviceID:); cancel consults DeviceTreeAddDevicePolicy to decide store.cancelPairing() vs. preserving a live connection; success triggers dismiss + async refresh, failures/cancels may restore previous Mac via store.switchToMac(macDeviceID:).
sequenceDiagram
  participant DeviceTreeView
  participant PairingView
  participant ShellStore
  participant Registry
  DeviceTreeView->>PairingView: present sheet / beginAddDevice()
  PairingView->>ShellStore: connect / cancel callbacks
  ShellStore->>DeviceTreeView: report connectionState, previousMacDeviceID
  DeviceTreeView->>ShellStore: finishAddDevice(previousMacDeviceID)
  ShellStore->>Registry: refreshPairedMacs() & refreshRegistryDevices()
  ShellStore->>ShellStore: switchToMac(macDeviceID) (restore path)
Loading

🎯 3 (Moderate) | ⏱️ ~20 minutes

"I hopped to the device tree with glee,
A plus-button blossom for pairing to be,
Policy whispers when to reset or keep,
Async refreshes wake devices from sleep,
🐇✨"


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Cmux Swift Concurrency ❌ Error DeviceTreeView.swift adds a fire-and-forget Task { await store.loadPairedMacs(); await store.loadRegistryDevices() } in finishAddDeviceIfConnected() without storing/canceling it. Replace that fire-and-forget Task with structured async (e.g., make the caller async and await the loads) or store/cancel the Task via view state so lifecycle is controlled.
✅ Passed checks (20 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: adding an always-available 'Add device' entry point to the iOS Devices sheet that enables re-entry into the pairing flow.
Docstring Coverage ✅ Passed Docstring coverage is 92.31% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed No Swift 6 actor-isolation regression: DeviceTreeAddDevicePolicy is stateless Sendable; CMUXMobileShellStore aliases MobileShellComposite marked @MainActor, and PairingView runs connect callbacks i...
Cmux Swift Blocking Runtime ✅ Passed In DeviceTreeView.swift and DeviceTreeAddDevicePolicy.swift, no Task.sleep, DispatchQueue.asyncAfter, DispatchQueue.main.sync, semaphores, locks, or sleep/polling primitives were found.
Cmux Expensive Synchronous Load ✅ Passed Reviewed DeviceTreeAddDevicePolicy.swift and DeviceTreeView.swift (plus tests): no added/moved RestorableAgentSessionIndex.load or other expensive sync loaders onto main/interactive paths; loads re...
Cmux Cache Substitution Correctness ✅ Passed Reviewed new Swift policy/tests and DeviceTreeView changes; they don’t substitute cached/opportunistic values for authoritative reads in persistence/history/undo/snapshot paths—only in-memory pairi...
Cmux No Hacky Sleeps ✅ Passed PR #6017 changes only 3 .swift files; no TS/JS/shell/build/runtime script diffs, so the no-hacky-sleeps rule isn’t triggered.
Cmux Algorithmic Complexity ✅ Passed New policy is O(1) predicate logic (no filter/sort/map); view additions just toggle sheet and run Tasks (no .sorted; no new nested scans detected).
Cmux Swift @Concurrent ✅ Passed Scanned PR Swift files (DeviceTreeAddDevicePolicy.swift, DeviceTreeView.swift, tests): no nonisolated async or invalid @concurrent usage introduced; new async work uses Task { await ... } cal...
Cmux Swift File And Package Boundaries ✅ Passed Policy is a small 66-line pure model type (no UI/network/parsing) and DeviceTreeView is 390 lines of UI wiring with store calls only; no production file exceeds the 400-line boundary.
Cmux Swift Logging ✅ Passed In the PR’s Swift files (DeviceTreeAddDevicePolicy.swift, DeviceTreeAddDevicePolicyTests.swift, DeviceTreeView.swift) there are no print/debugPrint/dump/NSLog calls and no file-scoped Logger consta...
Cmux User-Facing Error Privacy ✅ Passed Checked the PR’s described Swift files for user-facing error/copy literals and sensitive patterns per user-facing-errors.md; none of the disallowed upstream/provider/secret/payload details were added.
Cmux Full Internationalization ✅ Passed New UI text in DeviceTreeView uses L10n.string (mobile.deviceTree.title/mobile.addDevice.title/mobile.common.done); those keys exist in Localizable.xcstrings for en+ja, and no localization catalogs...
Cmux Swiftui State Layout ✅ Passed PR diff only adds @State isShowingAddDevice and a .sheet add-device flow; no @Published/ObservableObject, no GeometryReader, and no new store-held lazy/List row subtree in the diff.
Cmux Architecture Rethink ✅ Passed DeviceTreeAddDevicePolicy is pure; DeviceTreeView reuses a shared beginAddDevice action and has no Task.sleep/asyncAfter/locks/polling/NotificationCenter observer patterns added.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR #6017 only adds a SwiftUI .sheet-based PairingView from DeviceTreeView; no NSWindow/NSPanel/Window/WindowGroup/NSWindowController or cmux.* auxiliary-window identifier changes detected.
Cmux Source Artifacts ✅ Passed git diff origin/main..HEAD shows only intentional Swift source/tests plus config/localization; added files are .swift, and no DerivedData/build/tmp/artifact directories or generated logs/screensh...
Description check ✅ Passed The PR description is comprehensive and well-structured, covering all required sections: Summary explains what changed and why, Testing details how verification was done, entry points are documented, and a Checklist is present.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-devices-add-device

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bed9e483e7

ℹ️ 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".

finishAddDeviceIfConnected()
},
connectManualHost: { name, host, port in
await store.connectManualHost(name: name, host: host, port: port)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the live connection on failed add-device attempts

When this sheet is opened from an already connected device tree, reusing the first-run connectManualHost path means ordinary failure cases still tear down the active Mac: validation failures set connectionState = .disconnected and call clearRemoteConnectionContext(), and later connect failures do the same in MobileShellComposite.swift (checked connectManualHost). A typo/offline host from the new Add device row therefore disconnects the session the user was using even though the sheet remains open for retry; the QR closure above has the same underlying first-run behavior on invalid/offline codes.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an always-available "Add device" entry point to the iOS Devices sheet (DeviceTreeView), previously unreachable once a phone was paired. A new DeviceTreeAddDevicePolicy value type encodes the cancel/restore semantics needed when re-entering the destructive pairing flow over a live connection.

  • DeviceTreeAddDevicePolicy: pure, Sendable policy struct placed in CmuxMobileShellModel (platform-neutral, swift test-compatible) with full DocC and 8 unit tests covering all branches.
  • DeviceTreeView changes: adds a toolbar plus button and an always-visible "Add device" list row (both share a single beginAddDevice() action), presents PairingView as a sheet, and handles the three post-attempt outcomes — success/dismiss-and-refresh, cancel-over-live-connection/restore-previous-mac, and failure/keep-sheet-for-retry — through finishAddDevice.
  • The acknowledged architectural limitation (destructive store pairing path) is pre-existing and also present on the Settings → Switch Mac path; this PR is strictly safer than that existing entry point.

Confidence Score: 5/5

Safe to merge. Policy logic is sound, cancel/restore invariants are correctly wired, and the acknowledged limitations are pre-existing and explicitly documented.

The three changed files each address a single responsibility. Policy logic is pure and fully covered by 8 unit tests. The cancel-over-live-connection guard correctly suppresses cancelPairing() and the previousMacDeviceID capture+restore path is placed before the destructive async call. No new actor isolation issues, no new blocking primitives, no new user-facing strings.

No files require special attention.

Important Files Changed

Filename Overview
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/DeviceTreeAddDevicePolicy.swift New pure policy struct; stateless, Sendable, fully documented with DocC. Logic and edge-case handling are sound and match the test suite.
Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/DeviceTreeAddDevicePolicyTests.swift 8 unit tests covering all three policy methods across the meaningful state combinations. No issues.
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift Adds toolbar button, list row, and pairing sheet with correct cancel/restore policy wiring. The fire-and-forget Task pattern for switchToMac restore has a documented rationale and is consistent with existing patterns in the file.

Sequence Diagram

sequenceDiagram
    participant U as User
    participant TV as DeviceTreeView
    participant PV as PairingView (sheet)
    participant ST as CMUXMobileShellStore

    Note over TV: User opens Devices sheet (connected)
    U->>TV: Tap Add device row or toolbar +
    TV->>TV: "beginAddDevice() isShowingAddDevice = true"
    TV->>PV: Present PairingView sheet

    alt User cancels without starting attempt
        U->>PV: Tap Cancel
        PV->>TV: cancelPairing()
        TV->>TV: "cancelResetsPairingState(.connected) = false"
        Note over TV: cancelPairing() NOT called - live connection preserved
        PV->>TV: "cancel() isShowingAddDevice = false"
    else User starts pairing attempt
        Note over TV: Capture liveMacDeviceID
        U->>PV: Tap Connect / Scan QR
        PV->>ST: connectPairingInput() or connectManualHost()
        Note over ST: Destructive: tears down live client

        alt Attempt succeeds
            ST-->>TV: "connectionState = .connected"
            TV->>TV: finishAddDevice() dismisses sheet
            TV->>ST: "Task { loadPairedMacs() + loadRegistryDevices() }"
        else Attempt cancelled (no connectionError)
            ST-->>TV: "connectionState = .disconnected, no error"
            TV->>TV: finishAddDevice() restores previous Mac
            TV->>ST: "Task { switchToMac(macDeviceID: previousID) }"
        else Attempt failed (connectionError set)
            ST-->>TV: "connectionState = .disconnected, error set"
            TV->>TV: finishAddDevice() no restore
            Note over PV: Sheet stays up, error visible for retry
        end
    end
Loading

Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +157 to +160
Task {
await store.loadPairedMacs()
await store.loadRegistryDevices()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unstructured fire-and-forget Task for device refresh

The Task { await store.loadPairedMacs(); await store.loadRegistryDevices() } is launched without a stored handle or structured cancellation. If DeviceTreeView is removed from the hierarchy (e.g., the user navigates away immediately after pairing) the refresh work continues in the background with no way to cancel it. Consider attaching this work to the view's lifetime by storing the handle or using a .task(id:) modifier keyed on a post-pairing signal, consistent with how the initial load is tied to the .task {} modifier above.

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!

Review follow-up: the shared pairing path is destructive on failure
(bad QR, offline preflight, manual-host validation, cancellation all
leave the shell disconnected), so an add-device attempt started over a
live connection could strand the user. Capture the live Mac's device id
when the attempt starts and reconnect it via the existing
switchToMac(macDeviceID:) when the attempt ends disconnected, matching
that API's own never-strand-the-user failure contract. The restore runs
in a fresh unstructured Task because a cancelled attempt's continuation
executes in an already-cancelled task.

Also reshape DeviceTreeAddDevicePolicy from a case-less namespace enum
into a Sendable value type with an initializer per package API policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
connectManualHost: { name, host, port in
let previousMacDeviceID = liveMacDeviceID
await store.connectManualHost(name: name, host: host, port: port)
finishAddDevice(previousMacDeviceID: previousMacDeviceID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retry loses restore Mac ID

Medium Severity

Each pairing attempt captures previousMacDeviceID from liveMacDeviceID at attempt start. After the first failure the shell is disconnected, so a retry captures nil and a second failed attempt will not invoke switchToMac to restore the Mac that was live when the sheet opened.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 21efbf9. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 21efbf9f5f

ℹ️ 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".

Comment on lines +91 to +93
.sheet(isPresented: $isShowingAddDevice) {
addDeviceSheet
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route sheet dismissal through pairing cancellation

When a user starts a pairing attempt and then dismisses this sheet with the system swipe/background gesture, none of PairingView's Cancel button logic runs; startPairingTask creates an unstructured Task that is only canceled from that button. The in-flight connectPairingInput/connectManualHost can therefore continue after the user has dismissed the sheet and still replace the active Mac or reconnect in the background. Please disable interactive dismissal while pairing or make sheet dismissal invoke the same cancellation path.

Useful? React with 👍 / 👎.

Comment on lines +190 to +191
Task {
await store.switchToMac(macDeviceID: previousMacDeviceID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the add-device failure message during restore

When an add-device attempt that started over a live connection fails, this restore path calls switchToMac, which goes through connectManualHost and beginPairingAttempt, clearing the store's pairing error before reconnecting the previous Mac. That leaves the add-device sheet open for retry but removes the failure message/guidance from the attempt that just failed, so users get no explanation of the bad QR/offline host they need to fix. Preserve that error separately or restore the previous connection without clearing the add-device error state.

Useful? React with 👍 / 👎.

Review follow-up: restoring via switchToMac begins a fresh pairing
attempt, which clears connectionError and erased the failure reason the
auto-presented pairing sheet was showing. Restore now requires the
attempt to have ended disconnected with no connection error, which is
exactly the store's cancellation path (every real failure sets one).
Failed attempts keep the error visible for an in-place retry;
reconnecting the previous Mac without losing the error needs a
store-owned error channel that survives reconnects (follow-up). Local
input validation in PairingView and the scanner's pairing-link filter
keep malformed input from reaching the destructive path at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift`:
- Around line 185-192: The unstructured Task can perform a stale restore; before
calling store.switchToMac(macDeviceID:), re-check the current connection state
from the authoritative source and only proceed if the restore is still valid.
Concretely, inside the spawned Task capture previousMacDeviceID but query the
live state (e.g. via store.connectionState or an async accessor on store) and
call addDevicePolicy.restoresPreviousConnection(connectionState: currentState,
previousMacDeviceID: previousMacDeviceID) again; only if that returns true then
await store.switchToMac(macDeviceID: previousMacDeviceID). This ensures
switchToMac is skipped for stale attempts and prevents overwriting newer user
actions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e9ba4365-2820-4e4e-88b3-f94bfca30536

📥 Commits

Reviewing files that changed from the base of the PR and between bed9e48 and 21efbf9.

📒 Files selected for processing (3)
  • Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/DeviceTreeAddDevicePolicy.swift
  • Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/DeviceTreeAddDevicePolicyTests.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift

Comment on lines +185 to +192
if addDevicePolicy.restoresPreviousConnection(
connectionState: state,
previousMacDeviceID: previousMacDeviceID
), let previousMacDeviceID {
let store = store
Task {
await store.switchToMac(macDeviceID: previousMacDeviceID)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard the restore task against stale-attempt races.

At Line 190, restore is launched in an unstructured Task without re-checking current state. If a user retries quickly, a stale restore from a prior failed attempt can still call switchToMac and override a newer attempt outcome.

Suggested fix
         if addDevicePolicy.restoresPreviousConnection(
             connectionState: state,
             previousMacDeviceID: previousMacDeviceID
         ), let previousMacDeviceID {
             let store = store
-            Task {
-                await store.switchToMac(macDeviceID: previousMacDeviceID)
+            Task { `@MainActor` in
+                // Skip stale restore if another attempt has already advanced state.
+                guard store.connectionState == .disconnected else { return }
+                await store.switchToMac(macDeviceID: previousMacDeviceID)
             }
         }
🤖 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/DeviceTreeView.swift`
around lines 185 - 192, The unstructured Task can perform a stale restore;
before calling store.switchToMac(macDeviceID:), re-check the current connection
state from the authoritative source and only proceed if the restore is still
valid. Concretely, inside the spawned Task capture previousMacDeviceID but query
the live state (e.g. via store.connectionState or an async accessor on store)
and call addDevicePolicy.restoresPreviousConnection(connectionState:
currentState, previousMacDeviceID: previousMacDeviceID) again; only if that
returns true then await store.switchToMac(macDeviceID: previousMacDeviceID).
This ensures switchToMac is skipped for stale attempts and prevents overwriting
newer user actions.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 790571e. Configure here.

// `finishAddDevice` restores the previous Mac.
if addDevicePolicy.cancelResetsPairingState(connectionState: store.connectionState) {
store.cancelPairing()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancel leaves pairing attempt running

High Severity

When the user cancels during an in-flight add-device pairing, the sheet only calls cancelPairing() if connectionState is not .connected. The connect path can drop the live client while connectionState still reads .connected, so cancel skips cancelPairing() and never invalidates the attempt. Pairing can then finish after the sheet is dismissed, switching the session or calling finishAddDevice as success.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 790571e. Configure here.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

The workflow-guard-tests failure (Validate Swift file length budget) is inherited from main, not this PR: #5651 grew Sources/TerminalNotificationStore.swift and Sources/Feed/FeedCoordinator.swift past the checked-in ledger. Fix: #6018. Once that lands, merging main here turns the guard green; this PR's own files are all under the 500-line threshold.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift (1)

189-198: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Re-check policy conditions inside the restore Task to prevent stale-attempt races.

The unstructured Task at lines 195-197 can perform a stale restore if the user retries quickly. The policy check at line 189 uses the captured state and live store.connectionError, but between that check and the Task's execution, the store state may change:

  1. A successful new attempt connects to a different device → the stale Task would override by restoring the old Mac.
  2. A failed new attempt sets connectionError → the stale Task would call switchToMac, clearing the actionable error the user needs to see (per the policy doc: "reconnect path begins fresh pairing which clears connectionError").
🔒 Proposed fix: re-check the full policy condition inside the Task
         if addDevicePolicy.restoresPreviousConnection(
             connectionState: state,
             previousMacDeviceID: previousMacDeviceID,
             hasConnectionError: store.connectionError != nil
         ), let previousMacDeviceID {
             let store = store
+            let policy = addDevicePolicy
             Task { `@MainActor` in
+                // Skip stale restore if state has changed (user already connected
+                // to another device, or a new attempt failed with an error).
+                guard policy.restoresPreviousConnection(
+                    connectionState: store.connectionState,
+                    previousMacDeviceID: previousMacDeviceID,
+                    hasConnectionError: store.connectionError != nil
+                ) else { return }
                 await store.switchToMac(macDeviceID: previousMacDeviceID)
             }
         }
🤖 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/DeviceTreeView.swift`
around lines 189 - 198, The current code captures state and then launches a Task
that unconditionally calls store.switchToMac(previousMacDeviceID), which can
race and perform a stale restore; inside the Task re-evaluate
addDevicePolicy.restoresPreviousConnection(connectionState: currentState,
previousMacDeviceID: previousMacDeviceID, hasConnectionError:
store.connectionError != nil) (using the live store/state rather than the
earlier captured `state`) and only call store.switchToMac(previousMacDeviceID)
if that re-check returns true and previousMacDeviceID is still non-nil; ensure
you reference the same symbols (addDevicePolicy.restoresPreviousConnection,
previousMacDeviceID, store.connectionError, store.switchToMac) so the Task uses
up-to-date conditions before performing the restore.
🤖 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.

Duplicate comments:
In `@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift`:
- Around line 189-198: The current code captures state and then launches a Task
that unconditionally calls store.switchToMac(previousMacDeviceID), which can
race and perform a stale restore; inside the Task re-evaluate
addDevicePolicy.restoresPreviousConnection(connectionState: currentState,
previousMacDeviceID: previousMacDeviceID, hasConnectionError:
store.connectionError != nil) (using the live store/state rather than the
earlier captured `state`) and only call store.switchToMac(previousMacDeviceID)
if that re-check returns true and previousMacDeviceID is still non-nil; ensure
you reference the same symbols (addDevicePolicy.restoresPreviousConnection,
previousMacDeviceID, store.connectionError, store.switchToMac) so the Task uses
up-to-date conditions before performing the restore.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7f686bd1-d79f-459e-8d2f-dbd7ba2b0e8a

📥 Commits

Reviewing files that changed from the base of the PR and between 21efbf9 and 790571e.

📒 Files selected for processing (3)
  • Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/DeviceTreeAddDevicePolicy.swift
  • Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/DeviceTreeAddDevicePolicyTests.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant