Skip to content

Add native iOS diff navigation around shared renderer - #7925

Closed
lawrencecchen wants to merge 64 commits into
mainfrom
feat-ios-mobile-diff-view
Closed

lawrencecchen wants to merge 64 commits into
mainfrom
feat-ios-mobile-diff-view

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

Adds a mobile working-tree diff surface to the iOS workspace menu.

The Mac returns an authenticated, workspace-scoped tracked and untracked patch. iOS renders it with the existing React/Pierre diff assets inside WKWebView while native SwiftUI owns changed-file navigation, stats, previous/next, refresh, loading, and errors.

Verified with the webview suite, CmuxMobileBrowser tests, a tagged macOS build, a full iOS workspace build, and an isolated simulator run against a three-file Git fixture.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
New authenticated RPC path and WebKit custom-scheme serving of patch/assets add integration surface, but changes are scoped to mobile iOS UI with explicit auth tests and generation-scoped render lifecycle guards.

Overview
Adds an iOS View Changes flow that loads a workspace working-tree patch from the paired Mac via mobile.diff.load, gated by mobile.diff.v1 and workspace-scoped attach-ticket auth.

CmuxMobileBrowser introduces native SwiftUI chrome (file list sheet, prev/next, refresh, errors) around the existing shared diff web assets in WKWebView, using a private cmux-mobile-diff-data scheme handler and a JS bridge to keep file selection in sync with MobileDiffState.

CmuxMobileShellUI wires the diff as a new workspace surface (above browser, below chat), toolbar entry, load lifecycle, and teardown when switching workspace or other chrome modes.

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


Summary by cubic

Adds a native iOS diff viewer around our shared web renderer. iOS loads a workspace‑scoped patch via mobile.diff.load, renders it in WKWebView, and serves assets/patch via a cmux-mobile-diff-data scheme with fast preload/caching, robust path handling, and race‑free refresh. Also fixes and isolates the file list sheet presentation.

  • New Features

    • Native UI + bridge: file list, prev/next, refresh/close, and loading/empty/error states. The web hides toolbar/sidebar when mobileNativeChrome is true and exposes window.__cmuxMobileDiff.selectFile; messages carry a generation; the diff surface sits above the browser and below chat.
    • Patch delivery and auth: host advertises mobile.diff.v1; mobile.diff.load uses workspace‑scoped attach tickets and builds a bounded working‑tree patch (6MB cap, limited untracked) with a 15s timeout; a coordinator coalesces requests and limits concurrency; external untracked helpers are disabled.
    • WebKit scheme: cmux-mobile-diff-data serves bundled assets and the current patch with synchronous responses, off‑main preloading, and lazy caching.
  • Bug Fixes

    • Rendering lifecycle: require a renderer “ready” ack before applying files/selection; add a render timeout to drop stale generations; republish active selection after file index; bound web render and request frames.
    • Robustness and cancellation: make the custom diff‑data scheme visible across actors; serialize scheme task cancellation, reject reentrant callbacks, correctly type the coordinator slot continuation, and coordinate diff load lifecycles; add Foundation‑backed process cancellation for Git (safe pre/post‑launch termination without PID reuse).
    • Refresh races: bound concurrent reloads with generation checks, cancel older loads, and preserve current selection across refreshes; treat “no changes” as ready and show a native empty state; close the diff when entering chat.
    • Symlink safety: render safe untracked symlinks without dereferencing; tests cover this path.
    • UI: fix and isolate the file list sheet context/presentation.

Written for commit 36687ad. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Introduced an iOS “Diff Viewer” surface to browse workspace changes, with a file list, previous/next navigation, refresh, close, and clear loading/empty/error states.
    • Added native integration to load diffs via a workspace-scoped mobile RPC, plus support for both tracked and untracked changes.
    • Added the web/native mobile diff bridge for synchronized file selection and updated mobile diff viewer styling and English/Japanese localized text.
  • Tests
    • Added coverage for capability/authorization rules, stale selection handling, file selection synchronization, and working-tree diff generation behavior.

@vercel

vercel Bot commented Jul 12, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 12, 2026 10:40am
cmux-staging Building Building Preview, Comment Jul 12, 2026 10:40am

@coderabbitai

coderabbitai Bot commented Jul 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds workspace diff loading from the Mac host, workspace-scoped authorization, an iOS diff surface with file navigation, and a WebKit bridge for rendering diff content and synchronizing file selection.

Changes

Mobile diff viewer

Layer / File(s) Summary
Diff contracts and viewer bridge
Packages/iOS/CmuxMobileBrowser/..., Packages/iOS/CmuxMobileShellModel/..., webviews/src/..., webviews/test/...
Adds shared diff document/state models and connects web-view file metadata, selection, native chrome layout, and tests.
Backend diff RPC and authorization
Sources/Mobile/..., Sources/TerminalController..., Sources/TerminalController.swift, cmux.xcodeproj/..., cmuxTests/..., Packages/iOS/CmuxMobileRPC/...
Advertises and authorizes mobile.diff.load, generates bounded tracked and untracked Git patches, dispatches the RPC, and tests workspace authorization and patch contents.
iOS RPC integration and active surface
Packages/iOS/CmuxMobileShell/..., Packages/iOS/CmuxMobileShellUI/...
Loads diffs through the shell RPC client, tracks diff mode, adds toolbar and picker actions, renders the active surface, and covers surface precedence.
Native diff pane and WebKit rendering
Packages/iOS/CmuxMobileBrowser/Sources/..., ios/cmux-ios.xcodeproj/..., ios/cmux/Resources/..., Resources/markdown-viewer/webviews-app/chunks/...
Adds the native pane, file list, WebKit scheme handler, generated viewer payloads, bundled resources, localization, and viewer theme/URL updates.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WorkspaceDetailView
  participant MobileShellComposite
  participant MobileCoreRPCClient
  participant MobileHostService
  participant TerminalController
  participant MobileDiffWebView
  WorkspaceDetailView->>MobileShellComposite: loadMobileDiff(workspaceID)
  MobileShellComposite->>MobileCoreRPCClient: send mobile.diff.load
  MobileCoreRPCClient->>MobileHostService: authorize workspace request
  MobileHostService->>TerminalController: dispatch mobile.diff.load
  TerminalController-->>MobileHostService: return Git patch document
  MobileHostService-->>MobileCoreRPCClient: return diff payload
  MobileCoreRPCClient-->>MobileShellComposite: decode MobileDiffDocument
  WorkspaceDetailView->>MobileDiffWebView: render loaded document
  MobileDiffWebView-->>WorkspaceDetailView: report file list and selection
Loading

Possibly related PRs

  • manaflow-ai/cmux#7116: Overlaps with WorkspaceDetailView toolbar, title, and presentation lifecycle changes.
  • manaflow-ai/cmux#7675: Directly relates to WorkspaceActiveSurface.derive(...) precedence and the added .diff surface.

Important

Pre-merge checks failed

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

❌ Failed checks (4 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift @Concurrent ❌ Error v2MobileDiffLoad is @MainActor and awaits MobileWorkingTreeDiffLoader.load(...) (git/process I/O) on the UI actor, with no explicit hop or @concurrent. Move the diff loading into a detached/background actor or mark the nonisolated async helper @concurrent so Git/file work leaves @MainActor.
Cmux Swift File And Package Boundaries ❌ Error FAIL: MobileWorkingTreeDiffLoader.swift is new app-target core logic in root Sources/, and MobileDiffWebView.swift mixes UIViewRepresentable, coordinator, WKURLSchemeHandler, and actor store. Move MobileWorkingTreeDiffLoader into a small SwiftPM target, and split MobileDiffWebView.swift so the view/coordinator is separate from the URL-scheme handler and patch store.
Cmux User-Facing Error Privacy ❌ Error mobile.diff.load returns API error bodies like “Workspace is not inside a Git repository” and “Could not start Git,” exposing implementation details. Change the diff loader/RPC errors to generic product language (e.g. “Couldn’t load changes”) and keep Git-specific diagnostics in logs/internal telemetry.
Cmux Swiftui State Layout ❌ Error MobileDiffFileList keeps a MobileDiffState reference inside a List subtree and row actions/readers use it directly instead of snapshot+closure data flow. Refactor the file list to pass immutable files/selectedFileID snapshots and a selectFile closure into rows, keeping the state object above the List boundary.
Docstring Coverage ⚠️ Warning Docstring coverage is 2.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (20 passed)
Check name Status Explanation
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 PASS: new diff UI/state is explicitly @MainActor, async work is funneled through an actor-backed scheme store, and no new isolation debt appears.
Cmux Swift Blocking Runtime ✅ Passed The new production Swift code uses async/await, MainActor state, and actors; no semaphores, sleeps, sync waits, timers, or locks were added. The lone while true is a pipe-drain loop.
Cmux Browser Automation Off-Main ✅ Passed Only a new mobile.diff.load branch was added; no browser.* automation routing or policy/test files changed.
Cmux Expensive Synchronous Load ✅ Passed No new synchronous agent-history load was added; the diff loader is async/off-main (Task.detached), and the iOS menu/UI paths only dispatch Tasks.
Cmux Cache Substitution Correctness ✅ Passed No persistence/history/undo/snapshot path swaps a fresh read for stale cache; the diff only uses event-driven UI caches with generation checks and stale/cold repair.
Cmux No Hacky Sleeps ✅ Passed No new fixed sleeps/polling appear in the JS/TS/runtime diffs; the only setTimeout found is pre-existing in App.tsx and untouched here.
Cmux Algorithmic Complexity ✅ Passed New paths are linear or explicitly bounded; no nested rescans or repeated sort/filter chains were introduced in production code.
Cmux Swift Concurrency ✅ Passed New async work uses async/await or actor-backed tasks; mobileDiffLoadTask is stored/cancelled, and the remaining Task/continuation uses are WKWebView/Process callback boundaries.
Cmux Swiftpm Lockfiles ✅ Passed Policy script passed: no cmux .gitignore ignores Package.resolved, Browser’s local dependency adds no remote pins, and the Xcode diff doesn’t change package references.
Cmux Swift Logging ✅ Passed Changed runtime Swift files add no print/debugPrint/dump/NSLog/Logger usage; the only NSLog found is existing and guarded by #if DEBUG.
Cmux Full Internationalization ✅ Passed All new iOS copy uses L10n.string and mobile.diff keys have en/ja translations; the web changes add no new user-facing copy.
Cmux Architecture Rethink ✅ Passed PASS: diff adds a single MobileDiffState owner plus required WKWebView/actor bridges; no sleeps, polling, locks, or duplicate state ownership were introduced.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No touched file adds a standalone NSWindow/NSPanel/WindowGroup or cmux.* window identifier; the PR only adds iOS views/surfaces and webview plumbing.
Cmux Source Artifacts ✅ Passed PASS — changed paths are intentional source/config/localization/checked-in web assets; no logs, temp dirs, build outputs, or scratch artifacts were added.
Cmux No Test Or Debug Seam In Production Source ✅ Passed The only changed production source is MobileWorkingTreeDiffLoader.swift, and it adds no DEBUG/test-only seam or testing accessor.
Cmux No Ambient Global State ✅ Passed No new file-scope funcs/vars or singletons; new state lives in owned types/actors, and added statics are constants or computed helpers.
Title check ✅ Passed The title clearly summarizes the main change: native iOS diff navigation around the shared renderer.
Description check ✅ Passed Summary and testing are present and detailed, though the demo video, review trigger, and checklist sections are missing.
✨ 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-ios-mobile-diff-view

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.

@blacksmith-sh

This comment has been minimized.

Comment thread Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift Outdated
Comment thread Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffPane.swift Outdated
@greptile-apps

greptile-apps Bot commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a native iOS viewer for workspace changes. The main changes are:

  • Workspace-scoped RPC loading for tracked and untracked changes.
  • Bounded Git patch generation with cancellation and explicit errors.
  • Shared WebKit diff rendering with generation-aware native bridging.
  • Native file navigation, refresh, loading, empty, and error states.
  • Capability checks, localization, and focused test coverage.

Confidence Score: 5/5

This looks safe to merge.

  • The recent fixes preserve refresh ordering and generation checks.
  • Git output and changed-file counts remain bounded with explicit failures.
  • Workspace-scoped authorization is enforced before attach credentials are forwarded.
  • No blocking issue remains in the updated code.

Important Files Changed

Filename Overview
Sources/MobileWorkingTreeDiffLoader.swift Builds bounded tracked and untracked patches, including safe symlink entries and explicit invalid-data handling.
Sources/MobileDiffProcessCancellation.swift Coordinates Git process launch and cancellation through Foundation without retaining raw process identifiers.
Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebViewCoordinator.swift Coordinates generation-aware WebKit navigation, rendering acknowledgements, timeouts, and native selection updates.
Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffPane.swift Provides native diff controls and presents an immutable file-list snapshot through item-based sheet state.
Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift Restricts attach-ticket forwarding for diff requests to the covered workspace.

Reviews (37): Last reviewed commit: "refactor: isolate diff sheet context" | Re-trigger Greptile

Comment on lines +697 to +704
let workspaceID = workspace.id
Task { @MainActor in
do {
let document = try await store.loadMobileDiff(workspaceID: workspaceID)
guard mobileDiffState === state else { return }
state.load(document)
} catch {
guard mobileDiffState === state else { return }

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.

P1 Older Refresh Replaces Newer Diff

Two refresh tasks for the same open MobileDiffState both pass the identity guard. If the earlier request finishes last, it replaces the newer patch, so rapid refreshes can leave the viewer showing stale working-tree data.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: each reload cancels its predecessor and checks cancellation plus state identity before applying.

— Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: each reload cancels its predecessor and validates cancellation plus state identity before applying.

— Claude Code

Comment on lines +105 to +114
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = state.errorMessage, state.document == nil {
ContentUnavailableView {
Label(L10n.string("mobile.diff.loadFailed", defaultValue: "Couldn’t Load Diff"), systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button(L10n.string("mobile.common.retry", defaultValue: "Retry"), action: reload)
}
} else if state.document != nil {

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.

P1 Refresh Errors Leave Stale Content

fail preserves the previous document, but this error branch only runs when document == nil. A failed refresh or WebView navigation therefore keeps showing the old patch without the error or retry action, making stale data look current.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: refresh errors replace stale content with a localized retry screen.

— Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: refresh errors replace stale content with a localized retry screen.

— Claude Code

guard untracked.status == 0 else {
throw LoadError(code: "git_error", message: stderrMessage(untracked.stderr, fallback: "Could not list untracked files"))
}
let paths = untracked.stdout.split(separator: 0).prefix(maximumUntrackedFiles)

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.

P1 Untracked Changes Are Silently Dropped

When a repository has more than 200 untracked files, prefix omits the rest but the RPC still returns success. The phone then presents an apparently complete working-tree diff with required changes missing and no truncation warning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: more than 200 untracked files now returns an explicit too_many_files failure.

— Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: more than 200 untracked files returns an explicit too_many_files failure.

— Claude Code

} catch {
throw LoadError(code: "git_error", message: "Could not start Git")
}
let output = stdout.fileHandleForReading.readDataToEndOfFile()

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.

P1 Patch Limit Applies After Buffering

Each Git command buffers all stdout before the 6 MiB check runs. A large tracked diff or one large untracked binary can allocate the entire patch first, causing severe memory pressure instead of stopping at the advertised limit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: both pipes are drained concurrently and retained output is capped while excess bytes are discarded.

— Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: Git pipes drain concurrently into bounded buffers; excess output is discarded and reported.

— Claude Code

Comment on lines +106 to +107
let output = stdout.fileHandleForReading.readDataToEndOfFile()
let errorOutput = stderr.fileHandleForReading.readDataToEndOfFile()

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.

P1 Sequential Pipe Draining Can Deadlock

The parent drains stdout to EOF before reading stderr. If Git fills the stderr pipe while stdout remains open, Git blocks on stderr and this thread blocks waiting for stdout, so the diff request never completes.

Rule Used: Flag new blocking or timing-based synchronization ... (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: stdout and stderr now drain concurrently and process completion uses terminationHandler.

— Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: stdout and stderr drain concurrently and completion uses terminationHandler.

— Claude Code

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

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

guard untracked.status == 0 else {
throw LoadError(code: "git_error", message: stderrMessage(untracked.stderr, fallback: "Could not list untracked files"))
}
let paths = untracked.stdout.split(separator: 0).prefix(maximumUntrackedFiles)

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 Report untracked-file truncation

When a workspace has more than 200 untracked files, this .prefix(maximumUntrackedFiles) silently drops the rest and still returns a successful patch, so the phone presents an incomplete diff with no indication that files were omitted. Please either fail with an explicit too-large/too-many-files error or include a complete result rather than truncating the review data.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: untracked-file truncation now fails explicitly instead of returning incomplete data.

— Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: untracked-file truncation now fails instead of returning incomplete data.

— Claude Code

Comment on lines +48 to +50
let trackedArguments = hasHead
? ["diff", "--no-ext-diff", "--binary", "HEAD", "--"]
: ["diff", "--no-ext-diff", "--binary", "--"]

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 Include staged files in unborn repos

For a repository with no HEAD, git diff -- compares the worktree to the index, so files staged for the first commit are omitted; they also no longer appear in ls-files --others, making the mobile diff look empty/incomplete. In the no-HEAD case, include the cached diff against the empty tree/root (or otherwise account for indexed files) before returning the document.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: unborn repositories include cached and worktree diffs, with staged-file coverage.

— Claude Code

let repositoryRoot = String(data: repoResult.stdout, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!repositoryRoot.isEmpty else {
throw LoadError(code: "not_found", message: "Workspace is not inside a Git repository")

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 Localize mobile diff load failures

AGENTS.md requires all user-facing strings to be localized, and this message is returned by mobile.diff.load and displayed by MobileDiffContent as Text(errorMessage), so non-English iOS users see English copy for common failures such as opening a non-Git workspace. Return a code and map it to localized L10n text on the client, or otherwise localize before display.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: iOS shows localized recovery guidance instead of host Git diagnostics.

— Claude Code

Comment thread Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift Outdated

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift (1)

666-693: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear mobileDiffState when toggling chat. toggleChatMode() only flips isChatMode/pinnedChatSessionID; if mobileDiffState stays set, exiting chat falls through to .diff because WorkspaceActiveSurface.derive prefers diff over terminal. Route chat enter/exit through the same surface-reset path.

🤖 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/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift`
around lines 666 - 693, Update toggleChatMode() to clear mobileDiffState when
entering or exiting chat, using the same surface-reset behavior as the toolbar
actions. Preserve the existing isChatMode and pinnedChatSessionID updates so
chat transitions cannot fall through to the diff surface.
🤖 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 `@cmuxTests/MobileHostAuthorizationTests.swift`:
- Around line 1168-1177: Update runGit to use `#require` for the
process.terminationStatus == 0 check instead of `#expect`, preserving the existing
fail-fast behavior so subsequent Git commands do not run after a failure.

In `@ios/cmux/Resources/Localizable.xcstrings`:
- Around line 2422-2426: Replace the singular file-count key
mobile.diff.fileCount with the existing plural-aware .one/.other catalog
pattern, defining the appropriate English and Japanese forms. Update the caller
that formats this count to select the correct plural localization based on the
file count, preserving localized output for both one file and multiple files.

In
`@Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffPane.swift`:
- Around line 128-166: Update MobileDiffFileList to read selectedFileID into an
immutable snapshot in the enclosing view body before the List(state.files)
content closure, then use that snapshot for the row checkmark and color
comparisons. Keep row actions using the existing state methods as needed, but do
not read observable properties such as state.selectedFileID inside the List row
closure.
- Around line 66-70: Update the file-count text in MobileDiffPane to use ICU
plural localization variants, selecting the `.one` key for exactly one file and
`.other` for all other counts, while preserving the localized count formatting
and existing display behavior.

In
`@Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift`:
- Around line 159-176: Add a concise safety rationale comment alongside the
`MobileDiffPatchSchemeHandler` `@unchecked Sendable` declaration and `NSLock`,
documenting that the lock guards shared `html` and `patch` data accessed across
WebKit scheme-handler and main-actor contexts, and that this is required because
`WKURLSchemeHandler` is not actor-isolated.
- Around line 63-86: Update apply(state:) so the current generation is marked as
handled before returning from the MobileDiffPatchSchemeHandler.assetsAvailable
failure path; set loadedGeneration before state.fail(message:) and the early
return, preventing repeated failure handling on subsequent renders while
preserving the existing missing-assets error.

In `@Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift`:
- Around line 339-343: Add test coverage in MobileCoreRPCClientTests for
requestNeedsStackAuthFallback with the "mobile.diff.load" request, validating
the workspace-selection and ticket-coverage behavior exposed by
MobileCoreRPCClient. Keep the new case consistent with the existing host-status
and workspace-action authorization tests.

In
`@Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift`:
- Around line 29-37: Add a chatTakesPrecedenceOverDiff test alongside
diffTakesPrecedenceOverBrowser, invoking WorkspaceActiveSurface.derive with chat
mode and a chosen chat session enabled, an active diff, no browser, and
asserting the result is .chat.

In `@Sources/TerminalController`+MobileDiff.swift:
- Around line 52-54: Sanitize user-facing errors in the TerminalController
diff-loading paths, including the guards around tracked, staged, and unstaged
git operations. Log the raw stderr server-side for diagnostics, but pass only a
generic product-level message or approved failure reason to LoadError and
state.fail(message:), avoiding filesystem paths and implementation details.

In `@webviews/src/mobile-diff-bridge.ts`:
- Around line 53-57: Update the mobile-diff bridge message in the surrounding
function to include the active state generation, then update the Swift message
handling near MobileDiffWebView’s files-message branch to read and validate that
generation before calling state.updateFiles(...). Ignore mismatched generations
so stale files and selectedItemId cannot replace the current state, while
preserving handling for matching messages.

In `@webviews/test/mobile-diff-bridge.test.ts`:
- Around line 4-21: Extend the mobile diff bridge tests beyond mobileDiffFiles
to cover installMobileDiffBridge lifecycle behavior: verify it publishes
window.__cmuxMobileDiff, posts files and selection through the WebKit handler,
and cleanup preserves a newer bridge instance instead of removing it.

---

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift`:
- Around line 666-693: Update toggleChatMode() to clear mobileDiffState when
entering or exiting chat, using the same surface-reset behavior as the toolbar
actions. Preserve the existing isChatMode and pinnedChatSessionID updates so
chat transitions cannot fall through to the diff surface.
🪄 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: dd014f06-4ba4-4037-94ff-c116185bbc53

📥 Commits

Reviewing files that changed from the base of the PR and between 1e602aa and d513385.

📒 Files selected for processing (27)
  • Packages/iOS/CmuxMobileBrowser/Package.swift
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffPane.swift
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffState.swift
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift
  • Packages/iOS/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/MobileDiffStateTests.swift
  • Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Diff.swift
  • Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDiffDocument.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
  • Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift
  • Resources/markdown-viewer/webviews-app/chunks/diffSurface.mjs
  • Sources/Mobile/MobileHostService+Capabilities.swift
  • Sources/Mobile/MobileHostService.swift
  • Sources/TerminalController+MobileDiff.swift
  • Sources/TerminalController.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/MobileHostAuthorizationTests.swift
  • ios/cmux-ios.xcodeproj/project.pbxproj
  • ios/cmux/Resources/Localizable.xcstrings
  • webviews/src/App.tsx
  • webviews/src/global.d.ts
  • webviews/src/mobile-diff-bridge.ts
  • webviews/src/styles.css
  • webviews/src/surfaces/diffSurface.tsx
  • webviews/test/mobile-diff-bridge.test.ts

Comment on lines +1168 to +1177
private func runGit(_ arguments: [String], at directory: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = ["-C", directory.path] + arguments
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
#expect(process.terminationStatus == 0)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use #require instead of #expect in runGit for fail-fast behavior.

#expect records a failure but does not throw, so if git init fails, the test continues into git add and git commit, cascading confusing failures. #require throws immediately, producing a single clear failure point.

♻️ Proposed fix
     private func runGit(_ arguments: [String], at directory: URL) throws {
         let process = Process()
         process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
         process.arguments = ["-C", directory.path] + arguments
         process.standardOutput = FileHandle.nullDevice
         process.standardError = FileHandle.nullDevice
         try process.run()
         process.waitUntilExit()
-        `#expect`(process.terminationStatus == 0)
+        `#require`(process.terminationStatus == 0)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func runGit(_ arguments: [String], at directory: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = ["-C", directory.path] + arguments
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
#expect(process.terminationStatus == 0)
}
private func runGit(_ arguments: [String], at directory: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = ["-C", directory.path] + arguments
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
`#require`(process.terminationStatus == 0)
}
🤖 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 `@cmuxTests/MobileHostAuthorizationTests.swift` around lines 1168 - 1177,
Update runGit to use `#require` for the process.terminationStatus == 0 check
instead of `#expect`, preserving the existing fail-fast behavior so subsequent Git
commands do not run after a failure.

Comment thread ios/cmux/Resources/Localizable.xcstrings Outdated
Comment thread Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffPane.swift Outdated
Comment thread Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffPane.swift Outdated
Comment thread Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift Outdated
Comment on lines +29 to +37
@Test func diffTakesPrecedenceOverBrowser() {
#expect(WorkspaceActiveSurface.derive(
isChatMode: false,
hasChosenChatSession: false,
hasActiveBrowser: true,
hasActiveDiff: true
) == .diff)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for chat-over-diff precedence.

diffTakesPrecedenceOverBrowser covers diff-vs-browser, but no test exercises hasActiveDiff: true together with isChatMode: true, hasChosenChatSession: true to confirm chat still wins. Given the precedence order is the exact mechanism protecting against the mutual-exclusion gap flagged in WorkspaceDetailView.swift, a test locking in "chat beats diff" would add real confidence here.

✅ Suggested additional test
`@Test` func chatTakesPrecedenceOverDiff() {
    `#expect`(WorkspaceActiveSurface.derive(
        isChatMode: true,
        hasChosenChatSession: true,
        hasActiveBrowser: false,
        hasActiveDiff: true
    ) == .chat)
}
🤖 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/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift`
around lines 29 - 37, Add a chatTakesPrecedenceOverDiff test alongside
diffTakesPrecedenceOverBrowser, invoking WorkspaceActiveSurface.derive with chat
mode and a chosen chat session enabled, an active diff, no browser, and
asserting the result is .chat.

Comment on lines +52 to +54
guard tracked.status == 0 else {
throw LoadError(code: "git_error", message: stderrMessage(tracked.stderr, fallback: "Git diff failed"))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Raw git stderr is forwarded verbatim as the user-facing error message.

stderrMessage returns the raw (truncated) stderr text from git, which becomes the RPC message surfaced to the iOS diff pane (state.fail(message:)). This can include local filesystem paths and other implementation-level details from the Mac, which the project's own error-copy guideline says not to expose in user-facing text/API error bodies. Consider logging the raw stderr server-side and returning a generic, sanitized message (or a small enum of known failure reasons) to the client.

As per coding guidelines: "Do not expose raw upstream errors, stack traces, ... User-facing errors should state what happened in product terms ... Keep provider, billing, database, and authentication implementation details in sanitized logs or internal telemetry, not user-visible text."

Also applies to: 58-60, 64-67, 112-115

🤖 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 `@Sources/TerminalController`+MobileDiff.swift around lines 52 - 54, Sanitize
user-facing errors in the TerminalController diff-loading paths, including the
guards around tracked, staged, and unstaged git operations. Log the raw stderr
server-side for diagnostics, but pass only a generic product-level message or
approved failure reason to LoadError and state.fail(message:), avoiding
filesystem paths and implementation details.

Source: Coding guidelines

Comment thread webviews/src/mobile-diff-bridge.ts Outdated
Comment on lines +4 to +21
test("mobile diff bridge preserves file order and stats", () => {
const source = {
paths: ["Sources/App.swift", "README.md"],
pathToItemId: new Map([
["Sources/App.swift", "app"],
["README.md", "readme"],
]),
statsByPath: new Map([
["Sources/App.swift", { added: 12, deleted: 3 }],
["README.md", { added: 2, deleted: 0 }],
]),
} as any;

expect(mobileDiffFiles(source)).toEqual([
{ id: "app", path: "Sources/App.swift", added: 12, deleted: 3 },
{ id: "readme", path: "README.md", added: 2, deleted: 0 },
]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test the bridge lifecycle, not only the mapper.

Add assertions that installMobileDiffBridge publishes window.__cmuxMobileDiff, posts files and selection to the WebKit handler, and cleanup does not remove a newer bridge instance.

🤖 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 `@webviews/test/mobile-diff-bridge.test.ts` around lines 4 - 21, Extend the
mobile diff bridge tests beyond mobileDiffFiles to cover installMobileDiffBridge
lifecycle behavior: verify it publishes window.__cmuxMobileDiff, posts files and
selection through the WebKit handler, and cleanup preserves a newer bridge
instance instead of removing it.

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

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

if state.isLoading, state.document == nil {
ProgressView(L10n.string("mobile.diff.loading", defaultValue: "Loading changes…"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = state.errorMessage, state.document == nil {

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 Show refresh failures over stale diffs

When a refresh fails after a previous successful load (or WebKit reports an asset/navigation error after state.load), MobileDiffState.fail leaves document non-nil, so this state.document == nil guard prevents the error view from rendering and the user keeps seeing the stale diff as if the refresh succeeded. Please either clear the document on failure or surface the error while a stale document is present.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: error UI takes precedence even when a prior document exists, with localized Retry.

— Claude Code

do {
let document = try await store.loadMobileDiff(workspaceID: workspaceID)
guard mobileDiffState === state else { return }
state.load(document)

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 Ignore stale diff refresh responses

When the user taps Refresh again before the first mobile.diff.load RPC returns, both unstructured tasks still reference the same MobileDiffState, so the older request can finish after the newer one and this state.load(document) will overwrite the newer patch. Track a per-request id or cancel the prior task before applying the response so only the latest refresh updates the pane.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: reload cancellation and state-identity checks reject stale responses.

— Claude Code

} catch {
throw LoadError(code: "git_error", message: "Could not start Git")
}
let output = stdout.fileHandleForReading.readDataToEndOfFile()

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 Enforce the patch cap while reading git output

For a workspace with a large tracked diff or a single large untracked/binary file, this buffers the entire git diff stdout before maximumPatchBytes is checked, so the 6 MB limit does not prevent the Mac from allocating or waiting on a much larger patch before it can return too_large. Please stream/read with a byte cap and terminate the git process once the limit is exceeded.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63ca18c: bounded concurrent pipe drains cap retained bytes during Git output.

— Claude Code

@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

♻️ Duplicate comments (1)
Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift (1)

67-103: 🩺 Stability & Availability | 🔴 Critical

Still an infinite fail/re-render loop when viewer assets are missing.

The assetsAvailable guard (lines 74-77) calls state.fail(...) without setting loadedGeneration/pendingGeneration. Since state.fail mutates errorMessage, which drives a re-render → updateUIView → apply(state:), the same generation re-enters this branch and calls state.fail again indefinitely for as long as the document is loaded and assets remain missing. This is the same issue raised in a prior review of this file and does not appear to have been addressed in this revision.

Note the same gap exists in the (unchanged) catch block at lines 100-102 for viewerHTML failures — worth covering with the same fix while here.

🐛 Mark the generation as handled before returning on failure
                     guard MobileDiffPatchSchemeHandler.assetsAvailable else {
+                        loadedGeneration = state.generation
                         state.fail(message: L10n.string("mobile.diff.assetsMissing", defaultValue: "Diff viewer assets are missing from this build."))
                         return
                     }
                 } catch {
+                    loadedGeneration = state.generation
                     state.fail(message: error.localizedDescription)
                 }

As per coding guidelines: "Do not mutate state from body or helpers called by body ... That creates a re-render feedback loop and pegs the main thread."

🤖 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/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift`
around lines 67 - 103, In apply(state:), mark the current generation as handled
before returning from both the assetsAvailable failure path and the viewerHTML
catch path, setting the appropriate loadedGeneration/pendingGeneration state so
repeated renders do not retry the same failed generation. Preserve the existing
failure messages and ensure pendingGeneration is cleared consistently.

Source: Coding guidelines

🤖 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/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift`:
- Around line 254-270: Add a concise comment immediately above the guard in
stopRequest explaining that activeRequests.remove must execute first to consume
requests stopped after begin, while short-circuiting prevents recording those
requests in stoppedRequests. Keep the existing guard behavior unchanged.

---

Duplicate comments:
In
`@Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift`:
- Around line 67-103: In apply(state:), mark the current generation as handled
before returning from both the assetsAvailable failure path and the viewerHTML
catch path, setting the appropriate loadedGeneration/pendingGeneration state so
repeated renders do not retry the same failed generation. Preserve the existing
failure messages and ensure pendingGeneration is cleared consistently.
🪄 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: 9783e253-613d-4781-8a77-4e779465e5c4

📥 Commits

Reviewing files that changed from the base of the PR and between d513385 and a776492.

📒 Files selected for processing (1)
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift

Comment on lines +254 to +270
func beginRequest(_ requestID: ObjectIdentifier) -> Bool {
guard stoppedRequests.remove(requestID) == nil else {
stoppedRequestOrder.removeAll { $0 == requestID }
return false
}
activeRequests.insert(requestID)
return true
}

func stopRequest(_ requestID: ObjectIdentifier) {
guard activeRequests.remove(requestID) == nil,
stoppedRequests.insert(requestID).inserted else { return }
stoppedRequestOrder.append(requestID)
if stoppedRequestOrder.count > 64 {
stoppedRequests.remove(stoppedRequestOrder.removeFirst())
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a short comment explaining the request-tracking race handling.

beginRequest/stopRequest correctly handle both "stop before begin" and "stop after begin" races, but the correctness of the "stop after begin" case relies on activeRequests.remove(requestID) mutating the set as a side effect even when it makes the guard's first condition fail (short-circuiting the stoppedRequests.insert call). That's subtle enough that a future edit could "simplify" it into a real bug. A one-line comment on why the guard is structured this way would help.

🤖 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/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift`
around lines 254 - 270, Add a concise comment immediately above the guard in
stopRequest explaining that activeRequests.remove must execute first to consume
requests stopped after begin, while short-circuiting prevents recording those
requests in stoppedRequests. Keep the existing guard behavior unchanged.

Comment on lines +148 to +152
private func failNavigation(with error: any Error) {
let nsError = error as NSError
guard nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled else { return }
state.fail(message: error.localizedDescription)
}

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.

P1 Gate stale navigation failures

WebKit navigation errors are not tied to the generation that started the navigation. If generation N fails with a non-cancellation error after generation N+1 starts or finishes loading, this callback calls state.fail on the current state and replaces the newer diff with an error screen. Scheme-handler errors such as .badURL can take this path. Track the generation for each navigation and ignore failures that no longer match state.generation, as the script-message and configuration paths already do.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

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!

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

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

for pathData in paths {
guard let path = String(data: Data(pathData), encoding: .utf8), !path.isEmpty else { continue }
let result = try await runGit(
["diff", "--no-index", "--no-textconv", "--binary", "--", "/dev/null", path],

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 Disable external diff for untracked files

When an untracked file is present and the user's environment/config defines an external diff helper, this git diff --no-index invocation will run that helper; I verified GIT_EXTERNAL_DIFF=... git diff --no-index does so, while the tracked diff path above explicitly passes --no-ext-diff. That can hang the mobile refresh or append helper-specific, non-patch output for untracked files, so add --no-ext-diff here too before accepting stdout as a Git patch.

Useful? React with 👍 / 👎.

private func failNavigation(with error: any Error) {
let nsError = error as NSError
guard nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled else { return }
state.fail(message: error.localizedDescription)

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.

P1 Gate failures by generation

Ignoring cancellation errors does not prevent an older navigation from failing after a newer generation loads. If generation N reports another error after generation N+1 succeeds, this calls state.fail on the current state and replaces the newer diff with an error screen. Associate each WKNavigation with its generation and ignore failures that no longer match state.generation.

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

🤖 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 `@cmuxTests/MobileDiffTests.swift`:
- Around line 50-58: Update the repository_root assertion in the
MobileWorkingTreeDiffLoader test to compare against
repository.resolvingSymlinksInPath().path rather than repository.path, while
leaving the other document and patch expectations unchanged.

In
`@Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileDiffAuthTests.swift`:
- Line 39: Replace the Date().addingTimeInterval fixture in the authorization
test with the suite’s injected virtual clock or a fixed deterministic expiry
value. Ensure time-dependent behavior is tested by advancing the virtual clock
explicitly rather than reading the real wall clock.

In `@Sources/MobileWorkingTreeDiffLoader.swift`:
- Around line 108-127: Update MobileDiffProcessCancellation and the
process-launch flow around process.run() to preserve cancellation that arrives
before Process.isRunning becomes true: record pending cancellation when
cancellation races with launch, then check that state immediately after
process.run() returns and terminate the process if needed. Keep the existing
terminationHandler and Task cancellation behavior intact.

In `@webviews/test/mobile-diff-bridge.test.ts`:
- Around line 22-30: Extend the mobile diff bridge tests beyond
mobileDiffMessage to cover installMobileDiffBridge/useMobileDiffBridge lifecycle
behavior: verify window bridge registration, WebKit postMessage forwarding, and
cleanup preserving a newer bridge instance rather than clobbering it.
🪄 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: fd7bd05c-ee06-4eaa-bee4-58ed94631d3c

📥 Commits

Reviewing files that changed from the base of the PR and between a776492 and 63ca18c.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (21)
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffPane.swift
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffState.swift
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift
  • Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileDiffAuthTests.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+MobileDiff.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
  • Resources/markdown-viewer/webviews-app/chunks/diffSurface.mjs
  • Sources/Mobile/MobileHostService.swift
  • Sources/MobileDiffProcessCancellation.swift
  • Sources/MobileWorkingTreeDiffLoadError.swift
  • Sources/MobileWorkingTreeDiffLoader.swift
  • Sources/TerminalController+MobileDiff.swift
  • Sources/TerminalController.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/MobileDiffTests.swift
  • cmuxTests/MobileHostAuthorizationTests.swift
  • ios/cmux/Resources/Localizable.xcstrings
  • webviews/src/App.tsx
  • webviews/src/mobile-diff-bridge.ts
  • webviews/test/mobile-diff-bridge.test.ts
💤 Files with no reviewable changes (1)
  • cmuxTests/MobileHostAuthorizationTests.swift

Comment on lines +50 to +58
let document = try await MobileWorkingTreeDiffLoader().load(directory: repository.path, title: "Fixture")
let patch = try #require(document["patch"] as? String)
#expect(document["repository_root"] as? String == repository.path)
#expect(document["title"] as? String == "Fixture")
#expect(patch.contains("diff --git a/tracked.txt b/tracked.txt"))
#expect(patch.contains("diff --git a/untracked.txt b/untracked.txt"))
#expect(patch.contains("+after"))
#expect(patch.contains("+new"))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== test file ==\n'
sed -n '1,220p' cmuxTests/MobileDiffTests.swift

printf '\n== loader symbols ==\n'
rg -n "MobileWorkingTreeDiffLoader|repository_root|show-toplevel|resolvingSymlinksInPath|temporaryDirectory" -S Sources cmuxTests

Repository: manaflow-ai/cmux

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== file list ==\n'
git ls-files | rg '^(Sources|cmuxTests)/.*Mobile.*Diff.*\.swift$|MobileWorkingTreeDiffLoader|WorkingTreeDiffLoader|MobileDiff'

printf '\n== candidate implementation outline ==\n'
fd -a 'MobileWorkingTreeDiffLoader.swift' Sources cmuxTests 2>/dev/null || true
fd -a 'WorkingTreeDiffLoader.swift' Sources cmuxTests 2>/dev/null || true

Repository: manaflow-ai/cmux

Length of output: 1124


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline Sources/MobileWorkingTreeDiffLoader.swift --view expanded

printf '\n== relevant source ==\n'
sed -n '1,240p' Sources/MobileWorkingTreeDiffLoader.swift

Repository: manaflow-ai/cmux

Length of output: 7690


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== repository_root references ==\n'
rg -n '"repository_root"|repositoryRoot|show-toplevel|resolvingSymlinksInPath|standardizedFileURL' Sources/MobileWorkingTreeDiffLoader.swift Sources/MobileDiff*.swift Sources/TerminalController+MobileDiff.swift Sources/MobileWorkingTreeDiffLoadError.swift

printf '\n== file with line numbers ==\n'
cat -n Sources/MobileWorkingTreeDiffLoader.swift | sed -n '1,260p'

Repository: manaflow-ai/cmux

Length of output: 9435


Normalize the expected repository root before comparing. MobileWorkingTreeDiffLoader returns Git’s top-level path, while repository.path comes from the temp directory URL; compare against repository.resolvingSymlinksInPath().path instead.

Suggested change
-        `#expect`(document["repository_root"] as? String == repository.path)
+        `#expect`(
+            document["repository_root"] as? String
+                == repository.resolvingSymlinksInPath().path
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let document = try await MobileWorkingTreeDiffLoader().load(directory: repository.path, title: "Fixture")
let patch = try #require(document["patch"] as? String)
#expect(document["repository_root"] as? String == repository.path)
#expect(document["title"] as? String == "Fixture")
#expect(patch.contains("diff --git a/tracked.txt b/tracked.txt"))
#expect(patch.contains("diff --git a/untracked.txt b/untracked.txt"))
#expect(patch.contains("+after"))
#expect(patch.contains("+new"))
}
let document = try await MobileWorkingTreeDiffLoader().load(directory: repository.path, title: "Fixture")
let patch = try `#require`(document["patch"] as? String)
`#expect`(document["repository_root"] as? String == repository.resolvingSymlinksInPath().path)
`#expect`(document["title"] as? String == "Fixture")
`#expect`(patch.contains("diff --git a/tracked.txt b/tracked.txt"))
`#expect`(patch.contains("diff --git a/untracked.txt b/untracked.txt"))
`#expect`(patch.contains("+after"))
`#expect`(patch.contains("+new"))
}
🤖 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 `@cmuxTests/MobileDiffTests.swift` around lines 50 - 58, Update the
repository_root assertion in the MobileWorkingTreeDiffLoader test to compare
against repository.resolvingSymlinksInPath().path rather than repository.path,
while leaving the other document and patch expectations unchanged.

Comment thread Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileDiffAuthTests.swift Outdated
Comment thread Sources/MobileWorkingTreeDiffLoader.swift Outdated
Comment on lines +22 to +30

test("mobile diff messages carry the renderer generation", () => {
expect(mobileDiffMessage(null, "item-1", 7)).toEqual({
type: "files",
files: [],
generation: 7,
selectedItemId: "item-1",
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Bridge lifecycle (install/cleanup/postMessage) still untested.

Only mobileDiffMessage's output shape is covered here; installMobileDiffBridge/useMobileDiffBridge (window bridge registration, WebKit postMessage call, and cleanup not clobbering a newer bridge instance) remain untested, as previously noted.

🤖 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 `@webviews/test/mobile-diff-bridge.test.ts` around lines 22 - 30, Extend the
mobile diff bridge tests beyond mobileDiffMessage to cover
installMobileDiffBridge/useMobileDiffBridge lifecycle behavior: verify window
bridge registration, WebKit postMessage forwarding, and cleanup preserving a
newer bridge instance rather than clobbering it.

Comment thread Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift Outdated

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift (1)

222-229: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift:222-229 — hop back to the main actor before finishing WKURLSchemeTasks. MobileDiffPatchStore is a plain actor, so finishRequest/failRequest call didReceive/didFinish on the actor executor, not the main thread. Move the pendingTask completion onto MainActor first.

🤖 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/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift`
around lines 222 - 229, Update the Task flow around MobileDiffPatchStore’s
beginRequest/content/finishRequest calls so WKURLSchemeTask completion occurs on
MainActor. Before invoking failRequest or finishRequest with pendingTask, hop to
MainActor while preserving the existing request lifecycle and error behavior.
🤖 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/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift`:
- Around line 222-229: Update the Task flow around MobileDiffPatchStore’s
beginRequest/content/finishRequest calls so WKURLSchemeTask completion occurs on
MainActor. Before invoking failRequest or finishRequest with pendingTask, hop to
MainActor while preserving the existing request lifecycle and error behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b3fca04a-e943-49a0-a83a-0777b0c523e9

📥 Commits

Reviewing files that changed from the base of the PR and between 63ca18c and bb2e596.

📒 Files selected for processing (3)
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffState.swift
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffWebView.swift
  • Packages/iOS/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/MobileDiffStateTests.swift

throw MobileWorkingTreeDiffLoadError(code: "git_error", message: "Could not list untracked files")
}
for pathData in paths {
guard let path = String(data: Data(pathData), encoding: .utf8), !path.isEmpty else { continue }

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.

P1 Reject undecodable file paths

git ls-files -z returns raw path bytes, and repository filenames are not guaranteed to be valid UTF-8. When this conversion fails, continue silently omits the untracked file while the RPC reports a complete diff. A repository with such a filename therefore shows incomplete changes on iOS. Return an explicit unsupported-path error instead of skipping the file.

@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: 55d4e166d3

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

}

private struct MobileDiffFileList: View {
let state: MobileDiffState

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 Pass snapshots into the diff file list

AGENTS.md's “Snapshot boundary for list subtrees” rule says a view with List/ForEach rows must not hold an @Observable store; this new sheet keeps MobileDiffState and its rows call state.selectFile. When the renderer posts file/selection updates or refresh toggles loading/error state while the sheet is open, every store mutation can invalidate the whole row subtree and reintroduce the Lazy/List cache churn the guideline is meant to prevent. Pass immutable files/selectedFileID snapshots plus a select closure into this sheet instead.

Useful? React with 👍 / 👎.

Comment thread Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileDiffState.swift Outdated

@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 using default effort and found 1 potential issue.

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 1a8c720. Configure here.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 36687adf Deployed Jul 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants