Skip to content

iOS voice: GPT Realtime across paired Macs - #7314

Closed
lawrencecchen wants to merge 35 commits into
mainfrom
feat-ios-voice-mode
Closed

lawrencecchen wants to merge 35 commits into
mainfrom
feat-ios-voice-mode

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 4, 2026 •

Copy link
Copy Markdown
Contributor

Three features in one branch, per the voiced-feature spec.

Nested settings. The iOS settings sheet is now a top-level list pushing dedicated pages: Terminal (shortcuts + workspace-list display), Browser (search engine picker, threaded into BrowserURLResolver), Voice, Notifications, About, Privacy, Troubleshooting. About links to https://cmux.com/privacy-policy, https://cmux.com/terms-of-service, and the new https://cmux.com/support page (added under web/app/[locale]/(legal)/support with en+ja messages and a footer link), plus acknowledgements for FluidAudio (Apache-2.0) and Parakeet TDT 0.6B v3 (CC-BY-4.0).

Voice engines. New Packages/iOS/CmuxVoice package. Apple speech recognition remains the zero-download default; Settings > Voice can download NVIDIA Parakeet v3 (FluidAudio pinned exact: 0.15.4, ~480 MB CoreML int8, on-device, 25 languages, auto language detection) with progress, cancel, delete, and backup exclusion. ComposerDictationController gained a recognition-backend seam so the engine choice also drives composer dictation; the Apple backend preserves the prior behavior and iOS 26 TCC-callback crash workarounds verbatim, and the Parakeet path requests only microphone permission.

Voice Mode. Mic icon on the workspace list opens a full-screen mode where the iPhone is a microphone for the connected Mac. A new Mac-side MobileFocusObserver emits focus.updated events (payload inline, 50 ms throttle, hash-diffed, gated to the active window's TabManager) whenever the mouse selects a different pane or workspace, so the phone's target card updates in real time. Finalized utterances insert into the focused terminal via the new mobile.voice.input RPC (no_focused_terminal when a browser or nothing is focused), with an auto-submit toggle; mobile.focus.get seeds the initial state. Capabilities focus.events.v1 / voice.input.v1; scoped attach tickets are denied both methods. Phone-side focus subscriptions are connection-generation aware: they resubscribe after reconnect/Mac switch and send mobile.events.unsubscribe on exit.

Parakeet streaming details that matter for review: FluidAudio's isConfirmed chunk flag is NOT utterance finality; the session accumulates confirmed+volatile text, emits cumulative partials, and emits exactly one final from finish() (prevents double-typing into the terminal). Audio buffers flow through a single ordered consumer (no per-buffer Tasks), and finish()/cancel() tear down the SDK actor so loaded CoreML models are released per mic session.

Tests: CmuxVoice download state machine (incl. URLError(.cancelled) → idle and superseded-attempt guard), browser search-engine resolver behavior, Mac-side focus snapshot hashing and mobile.voice.input routing (focused terminal receives text; non-terminal focus errs). All new user-facing strings have en+ja entries.

Plan/code/judge loop: planned and orchestrated by Fable, implemented by GPT 5.5 (xhigh) via codex exec in an isolated worktree, adversarially reviewed by a read-only Fable judge (round 1: 6 confirmed issues, all fixed and re-verified in round 2).

🤖 Generated with Claude Code


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


Note

High Risk
Introduces remote text insertion into Mac terminals with focus-mismatch checks and a large new voice/RPC surface plus a third-party ML dependency; mis-routing or capability skew between phone and Mac would be user-visible.

Overview
iOS settings are reorganized into a compact root list with navigation to dedicated pages (Terminal, Browser, Voice, Notifications, About, Privacy, Troubleshooting), including diagnostics, pairing help, and a full-screen Voice Mode entry when the Mac advertises the new capabilities.

Mobile browser gains persisted default search engine settings (DuckDuckGo / Google / Bing) and wires address-bar submission through BrowserURLResolver with the selected template.

Voice stack: new CmuxVoice (FluidAudio) powers downloadable on-device Parakeet models, vocabulary/boost settings, and ComposerDictationController is refactored behind a recognition backend seam (Apple backend preserved; factory can swap to Parakeet). MobileShellComposite adds capability gating, mobile.focus.get, focus.updated subscription (generation-aware reconnect), and mobile.voice.input with expected workspace/surface IDs for fail-closed targeting. VoiceModeView streams Mac focus (including pane layout preview), transcribes via Apple or Parakeet, and sends typed or spoken utterances with optional auto-submit and resend history.

RPC models (MobileFocusSnapshot, MobileVoiceInputResponse) and unit tests cover layout decoding, browser settings, and utterance history.

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


Summary by cubic

Adds iOS Voice Mode with two paths: dictation into the focused Mac terminal and a GPT Realtime mode that can target terminals across paired Macs. Also ships downloadable on‑device Parakeet models, vocabulary boosting, a default browser search picker, nested settings with diagnostics, and removes a stale mobile titlebar source reference.

  • New Features

    • Dictation Voice Mode: live Mac focus via focus.updated (includes a pane‑layout wireframe and emits an empty snapshot when no window is active), fail‑closed mobile.voice.input targeting using expected workspace/surface IDs, auto‑submit, a resendable utterance history (cap 50), a bottom type‑or‑dictate field, and watchdog‑protected sessions that hard‑cancel on dismiss. Dictation recovers when a supported engine becomes available.
    • GPT Realtime Voice Mode: full‑duplex speech with short‑lived server credentials, barge‑in, partial/final transcripts, and a simple conversation UI showing live computer/terminal counts. Constrained tools list visible terminals and deliver the latest user utterance to selected opaque targets across Macs; delivery still routes through mobile.voice.input and is gated by voice.targets.v1.
    • Voice engines: new CmuxVoice with Apple (default) and NVIDIA Parakeet via FluidAudio 0.15.4 — v3 int8 (~483 MB), v3 Compact int4 (~336 MB), v2 (~464 MB). Per‑engine rows support download/resume/cancel/retry/delete with real byte progress and off‑main deletion. VoiceSettingsStore persists engine, auto‑submit, and a GPT Voice toggle; custom vocabulary biases both engines with an optional CTC add‑on (~103 MB) for Parakeet; Voice Mode can auto‑bias visible screen terms.
    • Settings, browser, diagnostics: compact root list (Terminal, Browser, Voice, Voice Vocabulary, Notifications, About, Privacy, Troubleshooting). Browser adds a default search‑engine picker wired into BrowserURLResolver via MobileBrowserSettings. Troubleshooting adds Run Diagnostics with Copy Report and common‑issues help. Adds a site Support page and footer link.
    • RPC/tests: new MobileFocusSnapshot and MobileVoiceInputResponse; unit tests cover layout decoding, browser settings, voice routing/tool execution, Parakeet downloads, and session/runtime behavior.
  • Migration

    • Requires a Mac build exposing focus.events.v1 and voice.input.v1; GPT mode and multi‑Mac targeting are gated by voice.targets.v1. Scoped attach tickets are denied for mobile.focus.get and mobile.voice.input.
    • To use Parakeet, download a model in Settings > Voice; dictation and Voice Mode use it automatically. Optional vocabulary boosting requires the CTC add‑on in Settings > Voice Vocabulary.

Written for commit 047f193. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added iOS Voice Mode to dictate into the currently focused terminal, including mic controls, live transcript, and optional auto-submit.
    • Added voice engine selection (Apple or Parakeet v3) with downloadable model support and automatic fallback.
    • Added persisted browser search-engine selection and a multi-page iOS Settings experience (Terminal, Browser, Voice, Notifications, About, Privacy, Troubleshooting).
    • Added a mobile/host Voice Mode focus capability and a Diagnostics settings screen.
    • Added a website Support page and footer Support link.
  • Bug Fixes
    • Improved address bar submissions to resolve queries using the selected search engine.
    • Improved Voice Mode handling when no terminal is focused, with clearer guidance.

Restructure iOS settings into nested pages (Terminal, Browser, Voice,
Notifications, About, Privacy, Troubleshooting) with privacy policy,
terms, and support links to cmux.com, license acknowledgements, and a
new /support page on the website.

Add a CmuxVoice package with a voice-engine abstraction: Apple speech
recognition stays the zero-download default, and NVIDIA Parakeet v3
(FluidAudio 0.15.4, ~480 MB CoreML, on-device, 25 languages) is user-
downloadable from Settings > Voice with progress, cancel, and delete.
Composer dictation gains a recognition-backend seam so the engine
choice applies everywhere; the Apple path's iOS 26 crash workarounds
are preserved verbatim.

Add Voice Mode: the iPhone acts as a microphone for the paired Mac.
The Mac streams focus.updated events (new MobileFocusObserver, active-
window gated, 50ms throttle) so the phone shows in real time which
workspace/pane the mouse selected; finalized utterances insert into
that pane via the new mobile.voice.input RPC (no_focused_terminal when
a non-terminal pane is focused), with an auto-submit toggle. New
capabilities focus.events.v1 and voice.input.v1; scoped tickets are
denied. Focus subscriptions resubscribe per connection generation and
unsubscribe on exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 4, 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 22, 2026 8:30am
cmux-staging Building Building Preview, Comment Jul 22, 2026 8:30am

@coderabbitai

coderabbitai Bot commented Jul 4, 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 Voice Mode transcription, Parakeet model management, mobile focus/voice-input RPCs, browser search settings, a route-based iOS settings flow, and a localized web support page.

Changes

iPhone Voice Mode and Dictation Backend

Layer / File(s) Summary
Voice transcription and dictation contracts
Packages/iOS/CmuxVoice/..., Packages/iOS/CmuxMobileSupport/...
Defines transcription session, recognition update, dictation backend, voice engine, and Parakeet download types used by the voice features.
Apple and Parakeet transcription sessions
Packages/iOS/CmuxVoice/...
Implements Apple and Parakeet voice transcription sessions plus the FluidAudio Parakeet model downloader.
Composer dictation recognition backends
Packages/iOS/CmuxMobileSupport/..., ios/cmux/...
Implements Apple and Parakeet dictation backends and refactors the controller, audio engine, and app composition root to use a pluggable backend factory.
Parakeet model store and settings persistence
Packages/iOS/CmuxVoice/..., Packages/iOS/CmuxMobileShellUI/..., Resources/Localizable.xcstrings, ios/cmux/Resources/Localizable.xcstrings, ios/CHANGELOG.md
Adds Parakeet model lifecycle management, persisted voice settings, voice settings UI, and supporting localization/changelog copy.
CmuxVoice package manifest and workspace wiring
Packages/iOS/CmuxVoice/Package.swift, Packages/iOS/CmuxVoice/Package.resolved, Packages/iOS/CmuxMobileShellUI/Package.swift, Packages/iOS/CmuxMobileShellUI/Package.resolved, ios/cmuxPackage/Package.swift, ios/cmuxPackage/Package.resolved, cmux.xcworkspace/...
Adds the CmuxVoice Swift package and wires it into dependent manifests and the workspace.
VoiceModeView and settings entry points
Packages/iOS/CmuxMobileShellUI/..., WorkspaceListView.swift, WorkspaceListView+Toolbar.swift
Implements the full-screen Voice Mode UI, the workspace mic entry point, and the voice settings page.
App composition root wiring
ios/cmux/..., ios/cmuxPackage/...
Wires browser/voice settings stores and the Parakeet model store into the environment and configures the dictation backend factory.
MobileShellComposite Voice Mode client
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
Adds capability detection, focus subscription lifecycle, and sendVoiceInput RPC on the mobile shell client.

Mobile Browser Search Engine Settings

Layer / File(s) Summary
Search engine model and persisted settings
Packages/iOS/CmuxMobileBrowser/..., Tests/...
Defines search engines with display names/templates and a persisted settings store.
Address resolution and settings page wiring
Packages/iOS/CmuxMobileBrowser/..., Packages/iOS/CmuxMobileShellUI/...
Resolves submitted address text via the configured search template and adds an engine-picker settings page.

iOS Settings Navigation Restructure

Layer / File(s) Summary
Route-based navigation
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift
Replaces inline sections with builders and route-based NavigationStack destinations.
New standalone settings pages
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTerminalSettingsPage.swift, MobileAboutSettingsPage.swift, MobileNotificationsSettingsPage.swift, MobilePrivacySettingsPage.swift, MobileDiagnosticsSettingsPage.swift, MobileTroubleshootingSettingsPage.swift
Adds dedicated settings pages for terminal, about, notifications, privacy, diagnostics, and troubleshooting.
Terminal shortcuts presentation modes
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift
Adds .sheet/.pushed presentation support.
Localization and changelog
ios/cmux/Resources/Localizable.xcstrings, Resources/Localizable.xcstrings, ios/CHANGELOG.md
Adds new localized strings and changelog entries for the settings restructure and voice features.

Web Support Page

Layer / File(s) Summary
Support page, footer link, translations
web/app/[locale]/(legal)/support/page.tsx, web/app/[locale]/components/site-footer.tsx, web/messages/en.json, web/messages/ja.json
Adds a localized support page with email/GitHub/docs links and a footer navigation entry.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • manaflow-ai/cmux#4233: This PR’s mobile.voice.input handler maps sendInputResult outcomes into RPC errors, matching the terminal send/queue logic centralized in #4233.

Important

Pre-merge checks failed

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

❌ Failed checks (8 errors, 2 warnings)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error Prod Swift adds timing sync: ComposerDictationController uses Task.sleep watchdog, and MobileShellComposite adds sleep-based reconnect/backoff delays. Replace sleeps with explicit completion signals/notifications or a cancellable timer abstraction outside runtime synchronization.
Cmux Swift Concurrency ❌ Error MobileDiagnosticsSettingsPage adds Task.detached for filesystem scanning inside a SwiftUI view—ordinary background work outside a required boundary. Move the directory-size scan into structured, non-detached code (or a dedicated utility/actor) and call it from the view with a tracked task.
Cmux Swift @Concurrent ❌ Error The new Parakeet download path is plain async file/network work and is invoked from a MainActor settings action without @concurrent or a detached hop. Add @concurrent to ParakeetModelDownloading.download/FluidAudioParakeetModelDownloader.download, or move the download call into Task.detached; keep only progress/state updates on MainActor.
Cmux Swift File And Package Boundaries ❌ Error MobileDiagnosticsSettingsPage.swift is a new 415-line production file that mixes SwiftUI with permission/filesystem diagnostics logic, matching the boundary rule's oversized/mixed-responsibility fa... Extract the row-building and platform/permission checks into CmuxMobileSupport (or similar) behind a small value/provider API, leaving the SwiftUI page as a thin renderer.
Cmux Swift Logging ❌ Error Added file-scoped private let ... = Logger(...) in @MainActor types (MobileFocusObserver, ParakeetModelStore, MobileShellComposite) instead of nonisolated private let. Change those logger constants to nonisolated private let or a nonisolated helper, and keep any interpolated values redacted/public-safe.
Cmux Full Internationalization ❌ Error FAIL: web support/footer copy is only in en/ja despite 20 locales, and Resources/Localizable.xcstrings adds a key with only en/ja; MobileDiagnosticsSettingsPage still uses String(format:). Add the support section/footer keys to every web/messages/*.json, translate the new xcstrings key for all locales in Resources/Localizable.xcstrings, and replace String(format:) with a locale-aware formatter.
Cmux Architecture Rethink ❌ Error MobileFocusObserver adds a Combine/NotificationCenter side channel with cached hash state, duplicating TabManager focus ownership and enabling stale focus snapshots. Collapse focus ownership into TerminalController/TabManager, expose one typed snapshot stream, and remove the separate observer/cache/waiter layer.
Cmux No Ambient Global State ❌ Error ComposerDictationController.backendFactory (ComposerDictationController.swift:47) is mutable static runtime state; it should be an injected instance property. Move backend selection into a ComposerDictationController initializer/property and pass the closure from AppCompositionRoot (or the host that constructs it) instead of mutating a static.
Title check ⚠️ Warning The title claims GPT Realtime support across paired Macs, but the changes implement dictation Voice Mode and related voice features instead. Replace the title with a concise summary of the implemented iOS Voice Mode, Parakeet engine, and nested settings changes.
Description check ⚠️ Warning The description explains the changes but omits the required Testing, Demo Video, review trigger, and Checklist sections. Add the required template sections, including testing details, demo link or attachment, review trigger, and completed checklist.
✅ Passed checks (15 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 New stores/protocols are explicitly @MainActor or pure value types, and async work hops back to MainActor before mutating UI state; no off-main store access found.
Cmux Browser Automation Off-Main ✅ Passed Current diff touches only MobileDiagnosticsSettingsPage and budget files; browser automation router/policy files are unchanged, and existing browser.wait/eval/screenshot/cookie commands stay on the...
Cmux Expensive Synchronous Load ✅ Passed No agent-history/JSONL/transcript load was added on main/UI paths; the only new directory scan runs in Task.detached, and the rest are light settings/UI updates.
Cmux Cache Substitution Correctness ✅ Passed The new caches are freshness-checked: voice focus fetches fresh from the Mac then subscribes, and Parakeet install state reads disk directly via isInstalled/refreshInstalledState.
Cmux No Hacky Sleeps ✅ Passed Changed TSX/JSON runtime files add support/footer content only; scans found no setTimeout/setInterval/sleep/poll/retry waits.
Cmux Algorithmic Complexity ✅ Passed The diff only adds one-pass lookups/enumerations or fixed-size collections; no nested scans, per-item rescans, or repeated hot-path sorting/filtering were introduced.
Cmux Swiftpm Lockfiles ✅ Passed Policy checker passed; all SwiftPM dependency edits had matching package-local Package.resolved diffs, and no cmux-owned .gitignore ignores Package.resolved.
Cmux User-Facing Error Privacy ✅ Passed Voice-related errors stay generic (e.g. connect/click-terminal/download-again) and don’t expose raw vendor/internal details; vendor names only appear in user-configured settings/help copy.
Cmux Swiftui State Layout ✅ Passed No new ObservableObject/@Published/GeometryReader or render-time writes; changed list/form rows use @Observable/@bindable or value snapshots and closures, and WorkspaceListView stays snapshot-based.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PASS: The PR only adds iOS pages/sheets/full-screen UI; scans found no NSWindow/NSPanel/NSWindowController/WindowGroup or cmuxAuxiliaryWindowIdentifiers changes.
Cmux Source Artifacts ✅ Passed Changed paths are a SwiftUI source file and a checked-in .github TSV config; no logs, caches, temp, build output, or artifact dirs appear.
Cmux No Test Or Debug Seam In Production Source ✅ Passed No new test/debug-only seam was added to production Sources; the backend factory has a real production caller, and the DEBUG blocks are developer UI, not test observability.
✨ 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-voice-mode

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.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

@codex review

Comment thread Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/VoiceModeView.swift Outdated

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

ℹ️ 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 !isFinished else { return }
isFinished = true
audioContinuation.finish()
Task { [manager, continuation, updateTask, audioTask] in

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 Cancel Parakeet startup when finishing early

When the user stops or dismisses a Parakeet session while AsrModels.downloadAndLoad is still in progress, finish() closes audio and later cancels the manager, but it never cancels the startupTask created in init (unlike cancel()). That startup task can still complete and call loadModels/startStreaming after this cleanup, resurrecting the CoreML/ASR session or racing the next mic session. Please cancel/await the startup task as part of the graceful finish path.

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 8351ea6 and 9f35786: finish() now cancels the startup task and the finalize task awaits it; a cancelled/failed startup skips manager.finish() and cleans up. The audio task also waits for startup success before forwarding, so a resurrected pipeline cannot race the next session.

— Claude Code

Comment on lines +25 to +28
self.task = recognizer?.recognitionTask(
with: request,
resultHandler: Self.makeRecognitionResultHandler(continuation: continuation)
)

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 Fail Apple voice sessions when no recognizer exists

On devices/locales where SFSpeechRecognizer() returns nil, this optional chain leaves task nil while the AsyncStream remains open. Voice Mode still starts the microphone and flips to listening after the audio engine starts, so users get an active mic with no transcript or error, and finish() cannot ever finish the stream because there is no recognition task. Please reject unsupported recognizers up front or yield a failure and close the stream here.

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 8351ea6: a nil SFSpeechRecognizer now yields a localized .failed and finishes the stream in init, so Voice Mode can never sit listening with no recognition task behind it.

— Claude Code

@greptile-apps

greptile-apps Bot commented Jul 4, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR ships three features together: nested iOS Settings pages, a downloadable NVIDIA Parakeet on-device ASR engine (via FluidAudio), and a full-screen Voice Mode that streams Mac focus state in real time and inserts transcribed speech into the focused terminal. The implementation is substantial (~9 k net lines) and the core correctness mechanisms—generation-aware focus subscriptions, fail-closed expected_workspace_id/expected_surface_id targeting, download-attempt supersession, and rename-aside async deletion—are all present and correct. Issues from the previous review round (localizedDescription user exposure, summaryHashForTesting debug seam) are confirmed fixed.

  • Parakeet download state machine (ParakeetModelStore, FluidAudioParakeetModelDownloader): retry on transient errors, per-attempt UUID guard against superseded callbacks, O(1) rename-aside delete with background byte removal, and pending-delete sweep on next launch. Package.resolved files for CmuxVoice, CmuxMobileShellUI, and ios/cmuxPackage are all updated with the pinned FluidAudio 0.15.4 revision.
  • Voice Mode focus loop (MobileShellComposite.startVoiceFocusUpdates): generation-aware, resubscribes after reconnect/Mac-switch, seeds focus after subscription to avoid lost updates during round-trip, and uses a CheckedContinuation waiter bag rather than polling to sleep between attempts.
  • Mac-side RPC (TerminalController+MobileVoiceInput, MobileFocusObserver): mobile.voice.input is fail-closed with workspace/surface ID checks; the observer is hash-diffed and 50 ms throttled, gated to the active window's TabManager, and correctly emits an empty snapshot when the last window closes.

Confidence Score: 5/5

Safe to merge. The fail-closed targeting guards (expected workspace/surface IDs checked server-side before any text is inserted) and generation-aware subscription loop are the highest-risk paths and both look correct.

All correctness-critical paths—text insertion into the focused terminal, focus subscription lifecycle, download state transitions, and error handling—have explicit guards or are covered by tests. The one notable gap (silent filesystem error on model delete in settings) is a UX inconvenience rather than a data-integrity issue. Localization is complete for all new user-facing strings. Package.resolved lockfiles are fully updated.

MobileVoiceSettingsPage.swift — the deleteModel(for:) error path swallows failures silently; worth a follow-up to surface a localised error in the UI.

Important Files Changed

Filename Overview
Sources/Mobile/MobileFocusObserver.swift New Mac-side focus observer; 50 ms throttled Combine subscriptions, hash-diffed to suppress no-op events, correctly gated to the active window's TabManager. Per-workspace subscription map is properly cleaned up when workspaces are removed.
Sources/TerminalController+MobileVoiceInput.swift New RPC handlers for mobile.focus.get and mobile.voice.input; fail-closed checks (expected_workspace_id / expected_surface_id, focused terminal guard, send-result error codes) all look correct. Error messages for no_focused_terminal and target_changed are localised.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Focus-subscription loop (startVoiceFocusUpdates) is generation-aware, properly handles reconnect and Mac switch, and uses a CheckedContinuation waiter bag to sleep between attempts without polling. sendVoiceInput sends expected IDs for fail-closed targeting.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/VoiceModeView.swift Full-screen Voice Mode view; session-generation token prevents stale audio-engine callbacks, hasVoiceTarget re-checked after permission prompt, onDisappear hard-cancels to avoid retained ASR/CoreML sessions.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileVoiceSettingsPage.swift Voice engine settings page; download/cancel/delete/retry rows correct. deleteModel(for:) uses try? and silently discards filesystem errors with no user feedback when deletion fails.
Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetModelStore.swift Download state machine: superseded-attempt UUID guard, cancellation mapped to idle/installed, O(1) rename-aside delete with background byte removal, pending-delete sweep on launch. localizedDescription no longer exposed to UI (fixed per previous thread).
Packages/iOS/CmuxVoice/Sources/CmuxVoice/FluidAudioParakeetModelDownloader.swift Streaming file download with 64 KB flush buffer (cancellation check per flush, not per byte), retry on transient errors, temp-file rename on completion, and progress remapping to hide FluidAudio's split from the UI.
Packages/iOS/CmuxVoice/Sources/CmuxVoice/AppleVoiceTranscriptionSession.swift Apple Speech session properly emits only non-empty transcripts, maps raw framework errors to cmux-domain copy with OSLog fallback, and correctly finishes the AsyncStream continuation in all paths.
Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift Refactored to use a backend seam; static factory justified in comments with a migration plan. isAvailable short-circuits the factory call when state != .unavailable so filesystem checks are not on the hot path.
web/app/[locale]/(legal)/support/page.tsx New legal support page under (legal) route group; uses next-intl for all text, en+ja messages present, footer link added. Landing-page registry parity rule does not apply to legal routes.

Sequence Diagram

sequenceDiagram
    participant iOS as VoiceModeView (iPhone)
    participant Shell as MobileShellComposite
    participant Mac as TerminalController (Mac)
    participant Obs as MobileFocusObserver

    iOS->>Shell: startVoiceFocusUpdates()
    Shell->>Mac: "mobile.events.subscribe {focus.updated}"
    Shell->>Mac: mobile.focus.get
    Mac-->>Shell: MobileFocusSnapshot (seed)
    Shell-->>iOS: voiceFocusSnapshot updated

    Obs->>Mac: focus changes detected (50ms throttle, hash-diff)
    Mac->>Shell: focus.updated event
    Shell-->>iOS: voiceFocusSnapshot updated

    iOS->>iOS: ASR → .final(text)
    iOS->>Shell: sendVoiceInput(text, expectedWorkspaceID, expectedSurfaceID)
    Shell->>Mac: mobile.voice.input
    alt IDs match focused terminal
        Mac-->>Shell: workspace_id, surface_id, surface_title
        Shell-->>iOS: MobileVoiceInputResponse
    else IDs stale
        Mac-->>Shell: error target_changed
        Shell-->>iOS: rpcError target_changed
    end

    iOS->>Shell: dismiss (task cancelled)
    Shell->>Mac: mobile.events.unsubscribe
Loading

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

Comment on lines +37 to +40
/// Factory consulted for every dictation start. The app composition root
/// swaps this to Parakeet when the user selected it and its model is installed.
public static var backendFactory: @MainActor () -> any ComposerDictationRecognitionBackend = {
AppleComposerDictationRecognitionBackend()

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 Ambient global state via public static var backendFactory

backendFactory is a mutable global closure stored at class scope and written by the composition root after construction (ComposerDictationController.backendFactory = { … }). This violates the no-ambient-global-state rule: the canonical shape is a backendFactory parameter in ComposerDictationController.init(), captured as a stored property and called on every start(). As written, any future second instance of ComposerDictationController silently inherits the application-wide factory, and tests that swap the factory leave it mutated for subsequent test cases unless they restore it.

Rule Used: Flag new ambient global state in production Swift:... (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.

Consciously kept as a composition-root seam, with the rationale now documented on the property (8351ea6): the two controllers are constructed where no DI path exists today (UIKit-hosted TerminalComposerView built by GhosttySurfaceRepresentable, and CmuxAgentChatUI which has no CmuxVoice dependency). AppCompositionRoot installs it once at process start before any composer view exists. Constructor injection lands when composer hosting is unified.

— Claude Code

Comment on lines +57 to +61
MobileHostService.shared.emitEvent(topic: "focus.updated", payload: snapshot.jsonObject())
}

#if DEBUG
static func summaryHashForTesting(tabManager: TabManager) -> Int {

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 Test-only seam in production Sources/

summaryHashForTesting is a #if DEBUG-guarded static method whose name explicitly declares its test-only purpose. Adding it to Sources/Mobile/MobileFocusObserver.swift (a production Sources/ path) is exactly the pattern the cmux-no-test-debug-seam-in-production-source rule prohibits. The canonical fix — per cmux PR 6452 — is to widen summaryHash from private to internal on MobileFocusSnapshotPayload and read it from the test target via @testable import, removing this accessor from the production type entirely.

Rule Used: Flag Swift files under a production Sources path (... (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 8351ea6: the #if DEBUG summaryHashForTesting accessor is removed. It was dead code; the tests already read MobileFocusSnapshotPayload.snapshot(...).summaryHash via @testable import.

— Claude Code

Comment on lines +80 to +82
defaultValue: "Model files were not found after download."
))
} catch {

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 error.localizedDescription surfaces raw implementation details in user-facing UI

self.state = .failed(error.localizedDescription) passes a raw system or SDK error string directly into ParakeetDownloadState.failed(String), which MobileVoiceSettingsPage renders verbatim as a red Text(message). URLErrors, CoreML compilation errors, and FluidAudio SDK errors produce strings like "The operation couldn't be completed. (NSURLErrorDomain error -1009.)" — exactly the implementation-detail exposure the cmux-user-facing-errors rule forbids. The same pattern appears in ParakeetTranscriptionSession.finish() (line 68 — continuation.yield(.failed(error.localizedDescription))) and AppleVoiceTranscriptionSession.makeRecognitionResultHandler (line 64), both of which feed the VoiceModeView errorMessage label. All three sites should translate the error to cmux-domain copy — e.g. "Couldn't download voice model. Check your connection and try again." — and reserve localizedDescription for sanitised logs only.

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 8351ea6: all three sites (ParakeetModelStore download failure, ParakeetTranscriptionSession startup/finalize, AppleVoiceTranscriptionSession recognition errors) now show localized cmux-domain copy (en+ja) and log the underlying error to OSLog.

— Claude Code

MobileShellComposite (+156: connection-generation-aware focus
subscription must live beside the private connection internals it
guards), TerminalAndGhosttyTests (+85: mobile.voice.input tests),
WorkspaceListView (+20: voice mode entry point), TerminalController
(+9), AppDelegate (+6), MobileHostService (+2): dispatch cases and
observer wiring at existing switch points. Also clear the stale
partial transcript when a Voice Mode session ends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetTranscriptionSession.swift Outdated

@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

case .denied:
self.onText = nil
state = .unavailable

P2 Badge Re-evaluate the selected dictation backend after denial

When the active backend is Apple and Speech authorization is denied, this puts the long-lived ComposerDictationController into .unavailable; the composer views keep that controller in @State, and start() will no longer reevaluate backendFactory because state.canStart fails. Since Parakeet only needs microphone permission, a user who denied Speech and then installs/selects Parakeet still has a disabled mic until the view/app is recreated. Please make the unavailable state backend-specific or reset it when the selected engine/model changes.

ℹ️ 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 +173 to +180
let engine = voiceSettings.effectiveEngine(modelInstalled: parakeetModelStore.isInstalled)
let permitted = await VoicePermissionRequester().requestPermissions(for: engine)
guard permitted else {
isStarting = false
errorMessage = L10n.string("mobile.voiceMode.permissionDenied", defaultValue: "Microphone or speech recognition permission is not available.")
return
}
guard isStarting else { return }

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 Re-check the Voice Mode target after permissions

On first use, the permission prompt can suspend here while the Mac focus moves away from a terminal, the host is switched, or the connection/capabilities change. After the await, only isStarting is checked, so Voice Mode can still start the microphone even though canStartListening has become false, leaving the user recording against no valid target and only failing later when sending the final transcript. Re-check canStartListening after permissions before creating the session.

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 9f35786: after the permission await, startListening re-checks the voice target (hasVoiceTarget: capabilities + focused-terminal snapshot) in addition to the generation/isStarting guards, and aborts the start when the target went away.

— 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: 20

🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAboutSettingsPage.swift`:
- Around line 21-38: The legal/support links in MobileAboutSettingsPage are
hardcoded and duplicated, so centralize them into shared URL constants or a
helper used by settingsLink instead of repeating the literal cmux.com URLs here
and in MobilePrivacySettingsPage. Update the existing MobileAboutSettingsPage
link calls to reference the shared symbols for privacy policy, terms of service,
and support so future URL changes only need one edit.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileNotificationsSettingsPage.swift`:
- Around line 12-29: The MobileNotificationsSettingsPage toggle button can start
overlapping enable()/disable() calls because repeated taps are allowed while the
Task is still running. Add simple in-flight state in the button action around
the existing pushCoordinator enable/disable flow, and use that state to disable
the Button until the current Task completes; locate the change in
MobileNotificationsSettingsPage’s Button and the notificationsEnabled toggle
logic.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileVoiceSettingsPage.swift`:
- Around line 128-130: The progressText(_:) helper in MobileVoiceSettingsPage
currently builds the percentage string with a fixed format, which ignores
locale-specific percent rendering. Update progressText(_:) to use locale-aware
percent formatting via the value’s formatted percent style so it follows the
user’s locale, and keep the change localized to that helper.
- Around line 94-110: The delete action in MobileVoiceSettingsPage’s .installed
branch resets voiceSettings.selectedEngine even when
ParakeetModelStore.deleteModel() fails because the error is swallowed by try?.
Change the Button action to only update the engine after a successful delete,
and handle the thrown error from deleteModel() explicitly so the selection stays
in sync with the actual model state. Use the existing
ParakeetModelStore.deleteModel, voiceSettings.selectedEngine, and .parakeetV3
handling to locate the fix.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/VoiceModeView.swift`:
- Around line 262-265: In VoiceModeView, replace the `String(format:)` usage for
`sendConfirmation` with `String.localizedStringWithFormat` so the localized
template from `L10n.string("mobile.voiceMode.sentToFormat", ...)` can handle
locale-aware substituted arguments correctly. Update the formatting at the
`sendConfirmation` assignment to use the localized string as the format source
and pass `title` through `String.localizedStringWithFormat`, keeping the
`mobile.voiceMode.sentToFormat` key and related localization behavior intact.
- Around line 267-269: The catch block in VoiceModeView is surfacing raw
upstream error text to the UI via error.localizedDescription. Replace that
assignment with a friendly, user-safe message and, if needed, log the detailed
error separately for diagnostics. Update the error handling around
store.sendVoiceInput so MobileVoiceModeError only receives sanitized copy, not
protocol/socket/internal details.

In
`@Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift`:
- Around line 37-41: `ComposerDictationController` is using a process-wide
mutable `backendFactory` static instead of an injected dependency. Move backend
selection into `ComposerDictationController.init(backendFactory:textMerger:)`
with a default factory closure that creates
`AppleComposerDictationRecognitionBackend`, and update the composition root to
pass the desired backend when constructing the controller rather than mutating
shared static state. Keep the factory as an instance-owned dependency so tests
and per-instance overrides don’t rely on global mutation.

In
`@Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationRecognitionBackend.swift`:
- Around line 16-18: The
`ComposerDictationRecognitionBackend.requestAuthorization` API still uses a
completion handler for a single boolean result; update the protocol to return
`async -> Bool` instead. Adjust
`ParakeetComposerDictationRecognitionBackend.requestAuthorization` to bridge the
existing permission callbacks with `withCheckedContinuation`, and update all
callers to `await` the result rather than passing a closure.

In
`@Packages/iOS/CmuxVoice/Sources/CmuxVoice/AppleVoiceTranscriptionSession.swift`:
- Around line 56-69: The callback in AppleVoiceTranscriptionSession should not
forward Speech framework errors directly to the UI through
.failed(error.localizedDescription). Update the error handling inside the
closure returned by the transcription session to emit a generic, localized
user-facing failure string instead, and keep the existing result/finish flow in
place for the transcript handling.
- Around line 15-29: In AppleVoiceTranscriptionSession.init(recognizer:), fail
fast when speech recognition cannot start instead of leaving the AsyncStream
open. Before creating the recognitionTask, check the recognizer for nil and
availability (including isAvailable, and keep the existing
supportsOnDeviceRecognition behavior), then immediately finish the continuation
with a failed VoiceTranscriptionUpdate or equivalent error state so
VoiceModeView’s updates loop does not hang. Use the existing recognizer,
request, continuation, and task setup in AppleVoiceTranscriptionSession to route
unavailable-recognizer cases into the same error handling path as
recognitionTask failures.

In `@Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetDownloadProgress.swift`:
- Around line 4-18: `ParakeetDownloadProgress.phaseDescription` is currently a
raw display string, which makes
`FluidAudioParakeetModelDownloader.progress(from:)` pass through hardcoded
English UI text. Change `ParakeetDownloadProgress` to use a `Sendable` enum for
the phase (for example the cases used by `progress(from:)`), then have the
UI/localization layer map each case to localized text with
`String(localized:defaultValue:)` instead of storing user-facing strings in the
model.

In `@Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetModelStore.swift`:
- Around line 82-89: The catch block in ParakeetModelStore.downloadModel is
passing error.localizedDescription directly into state.failed, which can expose
raw upstream/vendor messages in MobileVoiceSettingsPage. Replace that
user-facing text with a generic localized failure message, and log the original
error through the app’s structured logger for diagnostics; keep the cancellation
path unchanged and continue guarding with downloadAttemptID and isCancellation.

In `@Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetTranscriptionSession.swift`:
- Around line 35-38: The catch blocks in ParakeetTranscriptionSession are
surfacing raw upstream error text via error.localizedDescription, which can leak
internal model/provider details into VoiceModeView.handle(_) through the .failed
case. Update the failure path to yield a generic, localized user-facing message
instead of the upstream description, and keep the detailed error only in logs or
internal diagnostics. Apply the same mapping in both transcription/loading
failure paths referenced by the session’s error handling so all .failed
emissions are sanitized consistently.
- Around line 6-14: The ParakeetTranscriptionSession lifecycle is missing a
deallocation safety net, so startupTask, updateTask, and audioTask can keep
running if finish() or cancel() is never called. Add a deinit on
ParakeetTranscriptionSession that invokes cancel() so cleanup always happens
even when the session is dropped unexpectedly, and keep the cleanup logic
centralized in the existing cancel/finish path.
- Around line 76-86: `ParakeetTranscriptionSession.cancel()` is not idempotent
when `finish()` has already run or is running, so add the same `isFinished`
guard pattern used by `finish()` before doing any cleanup. Update `cancel()` to
return early if the session is already finished, and only then cancel
`startupTask`, `audioTask`, `updateTask`, finish `audioContinuation`, and spawn
the `Task` that calls `manager.cancel()` and `continuation.finish()`.

In `@Packages/iOS/CmuxVoice/Sources/CmuxVoice/VoicePermissionRequester.swift`:
- Around line 1-43: The permission request flow in VoicePermissionRequester
duplicates the mic/Speech authorization logic already handled by
AppleComposerDictationRecognitionBackend. Extract the shared
permission-negotiation behavior into one helper (for example in CmuxVoice or
CmuxMobileSupport) and have both
VoicePermissionRequester.requestPermissions(for:) and the backend’s
resolvedAuthorization()/requestAuthorization() paths call that shared API. Keep
the iOS version-specific microphone handling and any TCC workaround in the
shared helper so both callers stay in sync.

In `@Sources/Mobile/MobileFocusObserver.swift`:
- Line 5: The file-scoped Logger constant mobileFocusObserverLog is implicitly
MainActor-isolated here and should be marked nonisolated to avoid unintended
main-actor coupling. Update the declaration in MobileFocusObserver.swift to use
nonisolated private let for mobileFocusObserverLog, keeping the logger available
without inheriting the file’s default MainActor isolation.
- Around line 60-64: Remove the unnecessary DEBUG-only test seam from
MobileFocusObserver by deleting summaryHashForTesting(tabManager:), since tests
already access MobileFocusSnapshotPayload.snapshot(tabManager:).summaryHash
directly. Keep the production source focused on the existing snapshot logic and
do not add any replacement accessor or wrapper.

In `@Sources/TerminalController`+MobileVoiceInput.swift:
- Around line 6-18: Extract the nil-field “unresolved” payload into a shared
factory on MobileFocusSnapshotPayload and use it from v2MobileFocusGet instead
of constructing the literal inline. Update
MobileFocusSnapshotPayload.snapshot(tabManager:) to call that shared helper for
the unresolved path so both call sites stay in sync. Keep the shared symbol easy
to locate by naming it alongside MobileFocusSnapshotPayload and
v2MobileFocusGet.

In `@web/app/`[locale]/(legal)/support/page.tsx:
- Around line 13-17: The Support page metadata is hard-coding the canonical URL,
which breaks locale-aware SEO for localized routes. Update the metadata returned
from the support page’s generateMetadata logic to use the locale-aware
buildAlternates(locale, "/support") helper instead of a fixed
alternates.canonical value, matching the pattern used by the other localized
pages and keeping the locale-specific canonical in sync.
🪄 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: 8b84612b-aca8-4aff-be04-d8956bc8248b

📥 Commits

Reviewing files that changed from the base of the PR and between 3eabcfa and ac77d5d.

📒 Files selected for processing (66)
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserPane.swift
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserSearchEngine.swift
  • Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserSettings.swift
  • Packages/iOS/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/MobileBrowserSettingsTests.swift
  • Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileFocusSnapshot.swift
  • Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileVoiceInputResponse.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShellUI/Package.resolved
  • Packages/iOS/CmuxMobileShellUI/Package.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAboutSettingsPage.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileBrowserSettingsPage.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileNotificationsSettingsPage.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrivacySettingsPage.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTerminalSettingsPage.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTroubleshootingSettingsPage.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileVoiceSettingsPage.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/VoiceModeView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Toolbar.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/AppleComposerDictationRecognitionBackend.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationAudioEngine.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationRecognitionBackend.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationRecognitionUpdate.swift
  • Packages/iOS/CmuxVoice/Package.resolved
  • Packages/iOS/CmuxVoice/Package.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/AppleVoiceTranscriptionSession.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/FluidAudioParakeetModelDownloader.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetComposerDictationRecognitionBackend.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetDownloadProgress.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetDownloadState.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetModelDownloading.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetModelStore.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetTranscriptionSession.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/VoiceEngineID.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/VoicePermissionRequester.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/VoiceSettingsStore.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/VoiceTranscriptionSession.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/VoiceTranscriptionUpdate.swift
  • Packages/iOS/CmuxVoice/Tests/CmuxVoiceTests/ParakeetModelStoreTests.swift
  • Packages/iOS/CmuxVoice/Tests/CmuxVoiceTests/VoiceSettingsStoreTests.swift
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/Mobile/MobileFocusObserver.swift
  • Sources/Mobile/MobileFocusSnapshotPayload.swift
  • Sources/Mobile/MobileHostService+Capabilities.swift
  • Sources/Mobile/MobileHostService.swift
  • Sources/TerminalController+MobileVoiceInput.swift
  • Sources/TerminalController.swift
  • cmux.xcodeproj/project.pbxproj
  • cmux.xcworkspace/contents.xcworkspacedata
  • cmuxTests/MobileWorkspaceListFidelityTests.swift
  • cmuxTests/TerminalAndGhosttyTests.swift
  • ios/CHANGELOG.md
  • ios/cmux/AppCompositionRoot.swift
  • ios/cmux/Resources/Localizable.xcstrings
  • ios/cmux/cmuxApp.swift
  • ios/cmuxPackage/Package.resolved
  • ios/cmuxPackage/Package.swift
  • ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift
  • web/app/[locale]/(legal)/support/page.tsx
  • web/app/[locale]/components/site-footer.tsx
  • web/messages/en.json
  • web/messages/ja.json

Comment on lines +21 to +38
settingsLink(
title: L10n.string("mobile.settings.privacyPolicy", defaultValue: "Privacy Policy"),
systemImage: "hand.raised",
url: URL(string: "https://cmux.com/privacy-policy")!,
identifier: "MobileSettingsAboutPrivacyPolicy"
)
settingsLink(
title: L10n.string("mobile.settings.termsOfService", defaultValue: "Terms of Service"),
systemImage: "doc.text",
url: URL(string: "https://cmux.com/terms-of-service")!,
identifier: "MobileSettingsAboutTerms"
)
settingsLink(
title: L10n.string("mobile.settings.support", defaultValue: "Support"),
systemImage: "questionmark.circle",
url: URL(string: "https://cmux.com/support")!,
identifier: "MobileSettingsAboutSupport"
)

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

De-duplicate the hardcoded cmux.com legal/support URLs.

https://cmux.com/privacy-policy is hardcoded here and again in MobilePrivacySettingsPage.swift (and terms-of-service/support only appear here). If these URLs ever change, every occurrence must be updated in lockstep or they'll silently drift.

♻️ Proposed fix: shared URL constants
+private enum CmuxLegalURL {
+    static let privacyPolicy = URL(string: "https://cmux.com/privacy-policy")!
+    static let termsOfService = URL(string: "https://cmux.com/terms-of-service")!
+    static let support = URL(string: "https://cmux.com/support")!
+}
+
 struct MobileAboutSettingsPage: View {
     var body: some View {
         Form {
             ...
                 settingsLink(
                     title: L10n.string("mobile.settings.privacyPolicy", defaultValue: "Privacy Policy"),
                     systemImage: "hand.raised",
-                    url: URL(string: "https://cmux.com/privacy-policy")!,
+                    url: CmuxLegalURL.privacyPolicy,
                     identifier: "MobileSettingsAboutPrivacyPolicy"
                 )
🤖 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/MobileAboutSettingsPage.swift`
around lines 21 - 38, The legal/support links in MobileAboutSettingsPage are
hardcoded and duplicated, so centralize them into shared URL constants or a
helper used by settingsLink instead of repeating the literal cmux.com URLs here
and in MobilePrivacySettingsPage. Update the existing MobileAboutSettingsPage
link calls to reference the shared symbols for privacy policy, terms of service,
and support so future URL changes only need one edit.

Comment on lines +12 to +29
Button {
Task {
if notificationsEnabled {
await pushCoordinator.disable()
notificationsEnabled = false
} else {
notificationsEnabled = await pushCoordinator.enable()
}
}
} label: {
Label(
notificationsEnabled
? L10n.string("mobile.notifications.disable", defaultValue: "Turn Off Agent Notifications")
: L10n.string("mobile.notifications.enable", defaultValue: "Notify Me About Agents"),
systemImage: notificationsEnabled ? "bell.slash" : "bell"
)
}
.accessibilityIdentifier("MobileSettingsNotifications")

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 | 🔵 Trivial | ⚡ Quick win

No guard against overlapping toggle taps.

Rapidly tapping the button before the in-flight Task completes can fire a second enable()/disable() call while the first is still awaiting, since the button isn't disabled and there's no in-flight tracking. This is a minor UX edge case (likely self-recovering once both calls settle) rather than a functional blocker.

🔒 Optional guard against re-entrant taps
 struct MobileNotificationsSettingsPage: View {
     `@Environment`(MobilePushCoordinator.self) private var pushCoordinator
     `@State` private var notificationsEnabled = false
+    `@State` private var isUpdating = false

     var body: some View {
         Form {
             Section(L10n.string("mobile.settings.notifications", defaultValue: "Notifications")) {
                 Button {
+                    guard !isUpdating else { return }
+                    isUpdating = true
                     Task {
+                        defer { isUpdating = false }
                         if notificationsEnabled {
📝 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
Button {
Task {
if notificationsEnabled {
await pushCoordinator.disable()
notificationsEnabled = false
} else {
notificationsEnabled = await pushCoordinator.enable()
}
}
} label: {
Label(
notificationsEnabled
? L10n.string("mobile.notifications.disable", defaultValue: "Turn Off Agent Notifications")
: L10n.string("mobile.notifications.enable", defaultValue: "Notify Me About Agents"),
systemImage: notificationsEnabled ? "bell.slash" : "bell"
)
}
.accessibilityIdentifier("MobileSettingsNotifications")
struct MobileNotificationsSettingsPage: View {
`@Environment`(MobilePushCoordinator.self) private var pushCoordinator
`@State` private var notificationsEnabled = false
`@State` private var isUpdating = false
var body: some View {
Form {
Section(L10n.string("mobile.settings.notifications", defaultValue: "Notifications")) {
Button {
guard !isUpdating else { return }
isUpdating = true
Task {
defer { isUpdating = false }
if notificationsEnabled {
await pushCoordinator.disable()
notificationsEnabled = false
} else {
notificationsEnabled = await pushCoordinator.enable()
}
}
} label: {
Label(
notificationsEnabled
? L10n.string("mobile.notifications.disable", defaultValue: "Turn Off Agent Notifications")
: L10n.string("mobile.notifications.enable", defaultValue: "Notify Me About Agents"),
systemImage: notificationsEnabled ? "bell.slash" : "bell"
)
}
.accessibilityIdentifier("MobileSettingsNotifications")
🤖 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/MobileNotificationsSettingsPage.swift`
around lines 12 - 29, The MobileNotificationsSettingsPage toggle button can
start overlapping enable()/disable() calls because repeated taps are allowed
while the Task is still running. Add simple in-flight state in the button action
around the existing pushCoordinator enable/disable flow, and use that state to
disable the Button until the current Task completes; locate the change in
MobileNotificationsSettingsPage’s Button and the notificationsEnabled toggle
logic.

Comment on lines +128 to +130
private func progressText(_ value: Double) -> String {
String(format: "%.0f%%", value * 100)
}

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 | 🔵 Trivial | ⚡ Quick win

Percent text isn't locale-aware.

String(format: "%.0f%%", ...) hardcodes an English-style percent layout (e.g. French puts a space before %). Prefer .formatted(.percent...) for locale-correct rendering.

♻️ Proposed fix
     private func progressText(_ value: Double) -> String {
-        String(format: "%.0f%%", value * 100)
+        value.formatted(.percent.precision(.fractionLength(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 progressText(_ value: Double) -> String {
String(format: "%.0f%%", value * 100)
}
private func progressText(_ value: Double) -> String {
value.formatted(.percent.precision(.fractionLength(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
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileVoiceSettingsPage.swift`
around lines 128 - 130, The progressText(_:) helper in MobileVoiceSettingsPage
currently builds the percentage string with a fixed format, which ignores
locale-specific percent rendering. Update progressText(_:) to use locale-aware
percent formatting via the value’s formatted percent style so it follows the
user’s locale, and keep the change localized to that helper.

Comment thread Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/VoiceModeView.swift Outdated
Comment on lines +1 to +43
#if os(iOS)
import AVFoundation
import Foundation
import Speech

/// Requests microphone and Speech permissions for voice capture.
public struct VoicePermissionRequester {
/// Creates a permission requester.
public init() {}

/// Requests permissions needed by the selected voice engine.
/// - Parameter engine: The engine that will run.
/// - Returns: `true` when the required permissions were granted.
public func requestPermissions(for engine: VoiceEngineID) async -> Bool {
let micGranted = await Self.requestMicrophonePermission()
guard micGranted else { return false }
guard engine == .apple else { return true }
return await Self.requestSpeechPermission()
}

private nonisolated static func requestSpeechPermission() async -> Bool {
await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status == .authorized)
}
}
}

private nonisolated static func requestMicrophonePermission() async -> Bool {
await withCheckedContinuation { continuation in
if #available(iOS 17.0, *) {
AVAudioApplication.requestRecordPermission { granted in
continuation.resume(returning: granted)
}
} else {
AVAudioSession.sharedInstance().requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
}
}
}
#endif

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

Duplicate permission-negotiation logic vs. AppleComposerDictationRecognitionBackend.

This file re-implements mic + Speech authorization requesting that already exists in AppleComposerDictationRecognitionBackend.resolvedAuthorization()/requestAuthorization(). The two flows can drift — a future iOS-version workaround applied to one (like the existing TCC-callback crash fix) won't automatically apply to the other.

Consider extracting a single shared permission-negotiation helper in CmuxVoice/CmuxMobileSupport that both the composer dictation backend and Voice Mode's VoicePermissionRequester call into.

🤖 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/CmuxVoice/Sources/CmuxVoice/VoicePermissionRequester.swift`
around lines 1 - 43, The permission request flow in VoicePermissionRequester
duplicates the mic/Speech authorization logic already handled by
AppleComposerDictationRecognitionBackend. Extract the shared
permission-negotiation behavior into one helper (for example in CmuxVoice or
CmuxMobileSupport) and have both
VoicePermissionRequester.requestPermissions(for:) and the backend’s
resolvedAuthorization()/requestAuthorization() paths call that shared API. Keep
the iOS version-specific microphone handling and any TCC workaround in the
shared helper so both callers stay in sync.

Comment thread Sources/Mobile/MobileFocusObserver.swift
Comment thread Sources/Mobile/MobileFocusObserver.swift Outdated
Comment thread Sources/TerminalController+MobileVoiceInput.swift
Comment thread web/app/[locale]/(legal)/support/page.tsx
…cle, sanitized error copy

Codex structured review + PR bot findings on the voice feature:

- ParakeetTranscriptionSession: bound the mic-tap AsyncStream
  (bufferingNewest 1024) so a stalled/slow ASR consumer can no longer
  grow memory unboundedly while the user talks (P1). finish() now
  cancels and awaits the startup task so an early stop during CoreML
  load cannot resurrect the ASR session or race the next mic session,
  and startup/finalize failures surface localized cmux copy instead of
  raw error strings (detail goes to OSLog).
- AppleVoiceTranscriptionSession: a nil SFSpeechRecognizer now fails
  the stream up front instead of leaving Voice Mode listening forever
  with no transcript; recognition errors show localized copy.
- ParakeetComposerDictationRecognitionBackend: emit .finished when the
  update stream ends without a final result so a graceful stop settles
  immediately instead of waiting for the finalize watchdog.
- VoiceModeView: generation token + hard-cancel of the previous session
  on restart, so quick stop-then-start cannot leave two transcription
  sessions/taps live, and a stale engine-ready callback cannot flip
  isListening back on after a stop.
- ParakeetModelStore: download failures show localized copy; raw error
  detail moves to OSLog. Test updated to assert sanitized message.
- MobileFocusObserver: drop the unused #if DEBUG summaryHashForTesting
  seam (tests read MobileFocusSnapshotPayload internals directly).
- ComposerDictationController.backendFactory: document why the
  composition-root seam is static (consumers are constructed in
  UIKit-hosted and non-CmuxVoice packages with no DI path today).
- Localizable.xcstrings (iOS): en+ja entries for the five new
  user-facing voice error strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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: 8351ea60d4

ℹ️ 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 func emitIfNeeded(force: Bool) {
guard let tabManager else { return }
guard tabManager === TerminalController.shared.activeTabManagerForCallerNotification() else { return }

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 Emit an empty focus update when no window is active

When the active TabManager changes to nil (for example activateMainWindowContext(nil) calls setActiveTabManager(nil)), the active-manager notification wakes every MobileFocusObserver, but this guard makes each observer return before emitting anything. A phone already in Voice Mode then keeps showing the last terminal target instead of switching to “No terminal focused”, so the user can continue recording against a stale target until the final send fails. Emit a null focus snapshot on the nil-active transition rather than dropping the update.

Useful? React with 👍 / 👎.

Comment on lines +96 to 98
if !Self.backendFactory().isSupported {
state = .unavailable
}

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 Re-evaluate dictation support after Parakeet becomes usable

When this controller is constructed before Parakeet is installed/selected on a locale where SFSpeechRecognizer() is nil, this permanently sets the state to .unavailable. The new backendFactory is dynamic, but start is still blocked by state.canStart, so after the user downloads and selects Parakeet the existing composer mic remains disabled until the composer view is recreated. Re-check the current backend before keeping dictation unavailable instead of treating the initial Apple recognizer result as terminal.

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 80aa4b3 + 704f553: toggle() recovers from .unavailable by re-consulting the backend factory, and isAvailable now also asks the factory on read (pure) so the disabled mic button actually re-enables once a usable engine (e.g. installed+selected Parakeet) exists.

— Claude Code

lawrencecchen and others added 3 commits July 4, 2026 00:26
…arget re-check

- ParakeetTranscriptionSession: gate audio forwarding on startup completion
  so pre-load buffers wait in this session's BOUNDED stream instead of
  moving the unbounded backlog into FluidAudio's internal input stream.
- VoiceModeView: re-check the Voice Mode target after the permission
  prompt (focus/host/capabilities can change while it is up); keep the mic
  button tappable during isStarting so the coded cancel path is reachable
  when a permission prompt or engine spin-up hangs; sanitize send errors
  (Mac-authored RPC/auth messages pass through, transport errors map to
  localized copy); use String.localizedStringWithFormat for the send
  confirmation template.
- MobileVoiceSettingsPage: only flip the engine back to Apple when model
  deletion actually succeeded, so selection cannot disagree with the
  installed model.
- support page: use buildAlternates(locale, "/support") so localized pages
  do not self-canonicalize to the English URL.
- Localizable.xcstrings (iOS): en+ja for mobile.voiceMode.sendFailed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Main merged growth without a budget bump; PR CI runs on the merge
commit and inherited the violation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…deletion

- VoiceModeView: dismissing the screen (Done / onDisappear) now hard-cancels
  the transcription session and update task instead of a graceful stop, so a
  recognizer that never delivers a final result cannot keep the unstructured
  task and the ASR/CoreML session retained past the view lifetime.
- ParakeetModelStore.deleteModel(): the ~480 MB compiled model tree is renamed
  aside synchronously (O(1) same-volume rename, so isInstalled flips
  immediately and failures still throw) and the byte removal runs on a
  detached utility task, so the @mainactor store no longer blocks the
  settings UI. Orphaned rename targets from a mid-delete crash are swept in
  the background at store init.

Co-Authored-By: Claude Fable 5 <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.

Caution

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

⚠️ Outside diff range comments (2)
Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetTranscriptionSession.swift (2)

61-61: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Redact upstream error details in production logs.

error.localizedDescription can include local paths/CoreML/provider details; logging it as .public makes those details visible in unified logs. Keep the user-facing message generic and log the diagnostic as private.

🛡️ Proposed fix
-                parakeetSessionLog.error("Parakeet startup failed: \(error.localizedDescription, privacy: .public)")
+                parakeetSessionLog.error("Parakeet startup failed: \(error.localizedDescription, privacy: .private)")
...
-                parakeetSessionLog.error("Parakeet finalize failed: \(error.localizedDescription, privacy: .public)")
+                parakeetSessionLog.error("Parakeet finalize failed: \(error.localizedDescription, privacy: .private)")

As per coding guidelines, production Swift logs should keep dynamic sensitive/internal values redacted or private.

Also applies to: 114-114

🤖 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/CmuxVoice/Sources/CmuxVoice/ParakeetTranscriptionSession.swift`
at line 61, The Parakeet startup error logging in ParakeetTranscriptionSession
should not expose upstream details as public data. Update the
parakeetSessionLog.error call in the startup failure handling to keep the
user-facing message generic and log error.localizedDescription as private
instead of public, and apply the same redaction approach to the other matching
log site referenced in the review.

Source: Coding guidelines


200-205: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Copy the tap buffer before enqueueing. Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetTranscriptionSession.swift:80-81, 201-205 — AudioBufferBox suppresses Sendable checks while carrying a mutable AVAudioPCMBuffer reference across an async boundary. Copy the PCM data into owned storage first, or document a concrete guarantee that the engine never reuses or mutates that buffer after yield.

🤖 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/CmuxVoice/Sources/CmuxVoice/ParakeetTranscriptionSession.swift`
around lines 200 - 205, The AudioBufferBox path is passing a mutable
AVAudioPCMBuffer across an async boundary via unchecked Sendable, which should
be fixed in ParakeetTranscriptionSession. Update the audio enqueue flow in the
transcription session to copy the tap buffer’s PCM data into owned storage
before yielding, or otherwise make the ownership guarantee explicit and
verifiable so the buffer cannot be reused or mutated after handoff. Focus on the
tap-thread ingestion and the AudioBufferBox wrapper used by
ParakeetTranscriptionSession.

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.

Outside diff comments:
In `@Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetTranscriptionSession.swift`:
- Line 61: The Parakeet startup error logging in ParakeetTranscriptionSession
should not expose upstream details as public data. Update the
parakeetSessionLog.error call in the startup failure handling to keep the
user-facing message generic and log error.localizedDescription as private
instead of public, and apply the same redaction approach to the other matching
log site referenced in the review.
- Around line 200-205: The AudioBufferBox path is passing a mutable
AVAudioPCMBuffer across an async boundary via unchecked Sendable, which should
be fixed in ParakeetTranscriptionSession. Update the audio enqueue flow in the
transcription session to copy the tap buffer’s PCM data into owned storage
before yielding, or otherwise make the ownership guarantee explicit and
verifiable so the buffer cannot be reused or mutated after handoff. Focus on the
tap-thread ingestion and the AudioBufferBox wrapper used by
ParakeetTranscriptionSession.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 751c456a-0bdc-4332-af18-6f13d33a6fed

📥 Commits

Reviewing files that changed from the base of the PR and between 8351ea6 and 9f35786.

📒 Files selected for processing (5)
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileVoiceSettingsPage.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/VoiceModeView.swift
  • Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetTranscriptionSession.swift
  • ios/cmux/Resources/Localizable.xcstrings
  • web/app/[locale]/(legal)/support/page.tsx

Comment thread Packages/iOS/CmuxVoice/Sources/CmuxVoice/ParakeetTranscriptionSession.swift Outdated

@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: 05caff9d8d

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

await self?.unsubscribeVoiceFocus(client: client, streamID: streamID)
}
}
await unsubscribeVoiceFocus(client: client, streamID: streamID)

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 Avoid unsubscribing after the focus stream naturally ends

When the focus stream ends because the underlying RPC session is torn down (for example a disconnect or Mac switch), this unconditional unsubscribe sends a new request on the old MobileCoreRPCClient; MobileCoreRPCSession.tearDown finishes listeners and clears the transport, and a later sendRequest goes through ensureConnected, so this cleanup can reconnect a stale Mac just to unsubscribe. Only send the unsubscribe while the current connection is still alive; the cancellation handler already covers view dismissal.

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 80aa4b3: unsubscribeVoiceFocus is now generation-guarded (isCurrentRemoteOperation) so it is skipped after a disconnect or Mac switch and can no longer redial a stale Mac through ensureConnected.

— Claude Code

var body: some View {
Form {
Section {
Text(L10n.string("mobile.settings.privacy.voice", defaultValue: "Voice audio is transcribed on this iPhone."))

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 Correct the on-device-only privacy claim

When the selected engine is Apple on a locale/device where SFSpeechRecognizer.supportsOnDeviceRecognition is false, the Apple voice sessions do not set requiresOnDeviceRecognition, so Speech may use server recognition. This privacy page promises all voice audio is transcribed on the iPhone, which is inaccurate for that supported path; either require on-device recognition or soften the copy.

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.

Already fixed in 8dc0167 (before this review posted): the privacy row now reads 'Parakeet transcription always runs on this iPhone. The Apple engine prefers on-device recognition; when your language does not support it, Apple's servers may process the audio.' (en+ja).

— Claude Code

…t history

The nonisolated(unsafe) FileManager bridges compile locally but the
fleet builder's toolchain still rejects the sending-closure captures;
the detached delete/sweep now use FileManager.default inside the
closure (tests only ever inject .default). Privacy page no longer
claims unconditional on-device transcription: Apple's engine can use
Apple servers when the locale lacks on-device support (Parakeet is
always local). Voice Mode keeps at most 50 on-screen utterances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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: 8dc0167f3d

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

continue
}
let generation = connectionGeneration
_ = await fetchVoiceFocus()

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 Subscribe before fetching initial focus

Because the initial mobile.focus.get runs before the mobile.events.subscribe request is installed on the Mac, a focus change that happens after the fetch response but before the subscription ack is dropped by the host and voiceFocusSnapshot stays stale until another focus change. In that window Voice Mode can keep the mic enabled for the old terminal, or show one target while the final send goes to a different/no terminal; subscribe before the first fetch or refetch immediately after the subscription is acknowledged.

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 80aa4b3: the seed mobile.focus.get now runs after the mobile.events.subscribe ack (with a guarded unsubscribe on the immediate-supersede path), so a focus change during the subscribe round-trip is no longer lost.

— Claude Code

} header: {
Text(L10n.string("mobile.settings.voice.parakeet", defaultValue: "Parakeet v3"))
} footer: {
Text(L10n.string("mobile.settings.voice.footer", defaultValue: "Voice transcription is processed on this iPhone. Apple uses the built-in recognizer; Parakeet runs from a downloaded CoreML model."))

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 Correct the Voice settings privacy copy

Fresh evidence: although the Privacy page was qualified, this Voice page footer still says all voice transcription is processed on the iPhone. When the selected Apple engine runs on a locale/device where SFSpeechRecognizer.supportsOnDeviceRecognition is false, the Apple backends do not set requiresOnDeviceRecognition, so Speech may use Apple's servers; users choosing an engine from this page still see an inaccurate privacy claim. Please match the qualified copy here too.

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 80aa4b3: the Voice settings footer now carries the same qualified wording as the Privacy page (Parakeet always on-device; Apple prefers on-device and may use Apple's servers when the locale lacks support), en+ja.

— Claude Code

…cus emit

Voice settings now renders a model-driven engine list where each row
carries its own download state (Get 483 MB / progress + cancel / retry
/ installed + delete), so adding future models is one array entry; the
separate Parakeet section is gone and the size reflects the verified
HuggingFace tree (483 MB int8). Troubleshooting gains Run Diagnostics
(account, paired/active computer, connection route, Voice Mode
capability, notification and mic/speech permissions, model install
state, Copy Report; pure tested report builder) and eight collapsible
Common Issues entries with inline links into iOS Settings and
diagnostics. The Mac now emits one empty focus.updated when the active
TabManager goes away so Voice Mode cannot keep a stale target after
the last window closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@blacksmith-sh

This comment has been minimized.

…oller budget

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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: 50c5e2b4fe

ℹ️ 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 +256 to +260
utteranceHistory.markFailed(
id: id,
message: Self.sendErrorMessage(error),
isTargetChanged: Self.isTargetChanged(error)
)

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 Surface Voice Mode send failures

When sendVoiceInput throws because the Mac focus changed, no terminal is focused, the connection drops, or the terminal input queue rejects the text, this only records a .failed status in utteranceHistory; the visible transcript currently receives only utteranceHistory.utterances.map(\.text) and errorMessage is left unchanged, so the user sees the spoken text as if it was sent successfully. Please render the failed status or set the send error message here so Voice Mode has an explicit failure state.

AGENTS.md reference: AGENTS.md:L149-L149

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@cursor

cursor Bot commented Jul 24, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

1 similar comment
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

1 similar comment
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

# Conflicts:
#	Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
#	Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift
#	Resources/Localizable.xcstrings
#	cmux.xcodeproj/project.pbxproj
#	ios/cmux/Resources/Localizable.xcstrings
@greptile-apps

greptile-apps Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Too many files changed for review. (127 files found, 100 file limit)

Bypass the limit by tagging @greptile-apps to review.

@lawrencecchen lawrencecchen changed the title iOS voice: nested settings, downloadable Parakeet v3 engine, real-time Voice Mode iOS voice: GPT Realtime across paired Macs Jul 28, 2026
# Conflicts:
#	Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
#	cmux.xcodeproj/project.pbxproj
#	ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved
#	ios/cmux/AppCompositionRoot.swift
#	ios/cmux/cmuxApp.swift
#	ios/cmuxPackage/Package.resolved
#	ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift
#	web/app/env.ts
@cursor

cursor Bot commented Aug 1, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
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.

3 participants