Keep iOS mic button retryable after denial - #6462
lawrencecchen wants to merge 1 commit into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe dictation permission-denial path is changed from a terminal ChangesRetryable Dictation Permission Denial
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Possibly related PRs
Poem
Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (2 errors, 1 warning)
✅ Passed checks (20 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c5f9002. Configure here.
| .buttonStyle(.plain) | ||
| .foregroundStyle(listening ? AnyShapeStyle(Color.red) : AnyShapeStyle(TerminalPalette.foreground.opacity(0.7))) | ||
| .mobileGlassCircle() | ||
| .disabled(!dictation.isAvailable) |
There was a problem hiding this comment.
Mic enabled when unavailable
Medium Severity
Removing dictation.isAvailable and the mic’s .disabled modifier leaves the control tappable whenever ComposerDictationState is .unavailable (unsupported locale, nil recognizer, or failStart). start() still no-ops because canStart is false, so the UI suggests dictation works when it cannot.
Reviewed by Cursor Bugbot for commit c5f9002. Configure here.
Greptile SummaryThis PR fixes the iOS composer mic button becoming permanently disabled after the user taps "Don't Allow" on the speech or microphone permission prompt, by routing denied authorization back to
Confidence Score: 3/5The denial-to-idle fix is correct, but removing the The view change dropped the TerminalComposerView.swift — the missing Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
idle([idle])
requesting[requestingPermission]
listening[listening]
stopping[stopping]
unavailable([unavailable])
idle -->|"tap (canStart)"| requesting
requesting -->|"second tap (cancel)"| idle
requesting -->|"granted"| listening
requesting -->|"denied/restricted\n(new: retryablePermissionDenied)"| idle
listening -->|"tap stop / graceful stop"| stopping
stopping -->|"final result or watchdog"| idle
listening -->|"cancel / nav away"| idle
idle -->|"recognizer nil\nor failStart"| unavailable
style unavailable fill:#f99,stroke:#c33
style idle fill:#9f9,stroke:#3a3
style requesting fill:#ff9,stroke:#aa0
style listening fill:#9cf,stroke:#39c
style stopping fill:#fcf,stroke:#c3c
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
idle([idle])
requesting[requestingPermission]
listening[listening]
stopping[stopping]
unavailable([unavailable])
idle -->|"tap (canStart)"| requesting
requesting -->|"second tap (cancel)"| idle
requesting -->|"granted"| listening
requesting -->|"denied/restricted\n(new: retryablePermissionDenied)"| idle
listening -->|"tap stop / graceful stop"| stopping
stopping -->|"final result or watchdog"| idle
listening -->|"cancel / nav away"| idle
idle -->|"recognizer nil\nor failStart"| unavailable
style unavailable fill:#f99,stroke:#c33
style idle fill:#9f9,stroke:#3a3
style requesting fill:#ff9,stroke:#aa0
style listening fill:#9cf,stroke:#39c
style stopping fill:#fcf,stroke:#c3c
|
| .buttonStyle(.plain) | ||
| .foregroundStyle(listening ? AnyShapeStyle(Color.red) : AnyShapeStyle(TerminalPalette.foreground.opacity(0.7))) | ||
| .mobileGlassCircle() | ||
| .disabled(!dictation.isAvailable) | ||
| .accessibilityIdentifier("MobileComposerMic") |
There was a problem hiding this comment.
When the denial case was moved from
unavailable to idle, the entire .disabled guard was dropped rather than narrowed. unavailable is still the resting state for an unsupported locale or a failed audio setup, so those cases should still visually disable the button.
| .buttonStyle(.plain) | |
| .foregroundStyle(listening ? AnyShapeStyle(Color.red) : AnyShapeStyle(TerminalPalette.foreground.opacity(0.7))) | |
| .mobileGlassCircle() | |
| .disabled(!dictation.isAvailable) | |
| .accessibilityIdentifier("MobileComposerMic") | |
| .buttonStyle(.plain) | |
| .foregroundStyle(listening ? AnyShapeStyle(Color.red) : AnyShapeStyle(TerminalPalette.foreground.opacity(0.7))) | |
| .mobileGlassCircle() | |
| .disabled(dictation.state == .unavailable) | |
| .accessibilityIdentifier("MobileComposerMic") |
| case .denied: | ||
| self.onText = nil | ||
| state = .unavailable | ||
| return | ||
| // Keep denial retryable. The OS may return `false` immediately after | ||
| // a prior denial, but the user's next tap should still invoke the | ||
| // permission APIs instead of short-circuiting into a disabled state. | ||
| break | ||
| case .undetermined: | ||
| // First-ever request: fall through to the async prompt below. | ||
| break |
There was a problem hiding this comment.
Silent failure when permission is already denied — no "Open Settings" feedback
When resolvedAuthorization() returns .denied, the break falls through to requestAuthorization, which calls SFSpeechRecognizer.requestAuthorization. iOS will immediately invoke the callback with .denied without showing any alert or dialog (the system only prompts once per permission). The state cycles idle → requestingPermission → idle with no user-visible indicator. Users who previously tapped "Don't Allow" will see the mic button appear to do nothing, with no path to understand they must visit Settings → Privacy. Consider adding a "Go to Settings" alert from the guard granted else branch to make the denied path actionable.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift (1)
389-390:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winDisable the mic only in terminal
.unavailablestate to avoid inert taps.Line 389 keeps the mic visually/actionably enabled even when dictation is
.unavailable, but that tap path is a guaranteed no-op (startrejects non-.idle). Keep denial retryable, but prevent dead-end interaction for true unavailable conditions.Suggested fix
.buttonStyle(.plain) .foregroundStyle(listening ? AnyShapeStyle(Color.red) : AnyShapeStyle(TerminalPalette.foreground.opacity(0.7))) .mobileGlassCircle() + .disabled(dictation.state == .unavailable) .accessibilityIdentifier("MobileComposerMic")🤖 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/TerminalComposerView.swift` around lines 389 - 390, The mic button with accessibilityIdentifier "MobileComposerMic" remains enabled even when dictation is in an `.unavailable` state, causing taps to be dead-end interactions since the start action rejects non-idle states. Add a conditional disabled modifier to the mic button that disables it specifically when the dictation state equals `.unavailable`, preventing inert taps while keeping it interactive for other states like transient ones where retry should remain possible.
🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift`:
- Around line 389-390: The mic button with accessibilityIdentifier
"MobileComposerMic" remains enabled even when dictation is in an `.unavailable`
state, causing taps to be dead-end interactions since the start action rejects
non-idle states. Add a conditional disabled modifier to the mic button that
disables it specifically when the dictation state equals `.unavailable`,
preventing inert taps while keeping it interactive for other states like
transient ones where retry should remain possible.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: f6b561bc-54f4-41bb-98da-8c29cd863361
📒 Files selected for processing (4)
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftPackages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationTextMerge.swiftPackages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swiftPackages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/ComposerDictationTests.swift


Summary
Testing
swift test --filter ComposerDictationTests(fails before compilation: fresh worktree is missing a builtGhosttyKit.xcframework)./scripts/reload-cloud.sh --tag dog(passed, GitHub Actions run https://github.com/manaflow-ai/cmux/actions/runs/27853578184)/200,/handler/sign-in200,/handler/after-sign-in200 after redirect./ios/scripts/reload.sh --tag dog(blocked by local build guard for simulator reload)./ios/scripts/reload-cloud.sh --tag dog --device-id E4058DA9-F4C7-52DD-951D-0354061B8E89 --wait(archive/sign/install reported success, launch failed because CoreDevice lost the iPhone connection; follow-updevicectlreported the device unavailable)Issues
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
Low Risk
Localized iOS composer dictation permission UX; authorization still goes through system APIs, with no change to recognition or send paths.
Overview
Fixes the iOS composer mic staying disabled after the user taps Don’t Allow on speech or microphone permission.
Dictation controller: Denied or restricted authorization no longer moves to
unavailable. The flow clears the captured callback and returns to idle (viaretryablePermissionDenied), including when permissions were already denied synchronously—those taps now fall through to the async permission APIs instead of stopping early.unavailableis reserved for a missing recognizer or failed audio/recognition setup.UI: Removes
isAvailableand the mic button’s.disabled(!dictation.isAvailable)so the control stays tappable after denial.Tests: Asserts that
retryablePermissionDeniedis idle andcanStartis true.Reviewed by Cursor Bugbot for commit c5f9002. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Keep the iOS composer mic button tappable after speech/microphone permission denial so the next tap re-requests authorization. Only unsupported locale or audio setup failures now disable dictation. Addresses task: iOS mic button should not be disabled after “Don’t Allow”; tapping again should request permission again.
ComposerDictationState.retryablePermissionDenied; drop pending callback and resetbaseText.TerminalComposerView..unavailablefor nilSFSpeechRecognizeror audio setup failures; added a unit test to cover the retryable denial state.Written for commit c5f9002. Summary will update on new commits.
Summary by CodeRabbit
Release Notes
Bug Fixes
Tests