iOS composer: on-device voice dictation - #6197
Conversation
On-device speech-to-text for the composer, encapsulated in an @mainactor ObservableObject with a state machine (idle, requestingPermission, listening, stopping, unavailable). Prefers on-device recognition, falls back to server. The pure text-merge (base + transcript) is split into a host-testable type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a mic button (MobileComposerMic) beside the attach button that toggles dictation, shows a red mic.fill while listening, and is disabled when the recognizer is unavailable. Stops dictation on send, focus loss, onDisappear, and terminal switch so the mic never stays hot. Refreshes the file-length budget for the touched composer file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NSSpeechRecognitionUsageDescription and NSMicrophoneUsageDescription in Info.plist, plus English and Japanese values in InfoPlist.xcstrings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers the base + transcript merge (spacing, trailing whitespace, empty partials, growing partials) and the state machine's pure canStart/isListening transitions. The Speech/AVFoundation engine wiring is iOS-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds on-device voice dictation to the iOS terminal composer. A new ChangesVoice Dictation Feature
Sequence DiagramsequenceDiagram
actor User
participant TerminalComposerView
participant ComposerDictationController
participant SFSpeechRecognizer
participant AVAudioEngine
User->>TerminalComposerView: tap mic button
TerminalComposerView->>ComposerDictationController: toggle(existingText:onText:)
ComposerDictationController->>SFSpeechRecognizer: requestAuthorization()
ComposerDictationController->>AVAudioEngine: requestMicrophonePermission()
SFSpeechRecognizer-->>ComposerDictationController: authorized
AVAudioEngine-->>ComposerDictationController: granted
ComposerDictationController->>AVAudioEngine: prepare + start + installTap
ComposerDictationController->>SFSpeechRecognizer: recognitionTask(with:resultHandler:)
loop Streaming partials
AVAudioEngine->>SFSpeechRecognizer: audio buffer
SFSpeechRecognizer-->>ComposerDictationController: partial transcript
ComposerDictationController->>ComposerDictationController: TextMerge.merged(base:transcript:)
ComposerDictationController->>TerminalComposerView: onText(mergedText)
TerminalComposerView->>TerminalComposerView: store.terminalInputText = mergedText
end
SFSpeechRecognizer-->>ComposerDictationController: isFinal=true
ComposerDictationController->>ComposerDictationController: teardown (cancel task, stop engine, clear callbacks)
ComposerDictationController->>TerminalComposerView: state → .idle
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 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 (18 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryAdds on-device voice dictation to the iOS composer via a new
Confidence Score: 4/5Safe to merge with one blocking-runtime concern to address: the 2.5 s The controller design is solid —
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A([idle]) -->|tap mic| B([requestingPermission])
B -->|second tap| A
B -->|denied / restricted| E([unavailable])
B -->|granted| C([listening\naudio engine running])
C -->|tap mic| D([stopping\nwatchdog armed])
C -->|focus loss not lock-driven| D
C -->|send / onDisappear / terminal switch| A
C -->|isFinal or error callback| A
D -->|isFinal callback| A
D -->|watchdog fires 2.5 s| A
D -->|hard cancel| A
C -->|recognizer unavailable / channel=0 / engine error| E
%%{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
A([idle]) -->|tap mic| B([requestingPermission])
B -->|second tap| A
B -->|denied / restricted| E([unavailable])
B -->|granted| C([listening\naudio engine running])
C -->|tap mic| D([stopping\nwatchdog armed])
C -->|focus loss not lock-driven| D
C -->|send / onDisappear / terminal switch| A
C -->|isFinal or error callback| A
D -->|isFinal callback| A
D -->|watchdog fires 2.5 s| A
D -->|hard cancel| A
C -->|recognizer unavailable / channel=0 / engine error| E
Reviews (6): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile |
| /// tinted mic. Disabled when the recognizer is unavailable or permission was | ||
| /// denied so the user is never left tapping a dead control. | ||
| private var micButton: some View { | ||
| let listening = dictation.state.isListening | ||
| return Button { | ||
| toggleDictation() | ||
| } label: { | ||
| Image(systemName: listening ? "mic.fill" : "mic") |
There was a problem hiding this comment.
Missing Localizable.xcstrings entries for new mic L10n keys
mobile.composer.mic.start and mobile.composer.mic.stop are consumed via L10n.string but are absent from ios/cmux/Resources/Localizable.xcstrings. Every other mobile.composer.* key in that file has both en and ja entries. Without catalog entries, the accessibility label falls back to the hardcoded English defaultValue for Japanese users — the exact localization debt the cmux rule exists to prevent. Both keys need en+ja stringUnit blocks in Localizable.xcstrings.
Rule Used: Flag production user-facing text that is not fully... (source)
| } | ||
| } | ||
|
|
||
| // MARK: - Recognition | ||
|
|
||
| /// Configure the audio session, install the engine tap, and start the | ||
| /// recognition task. On any setup failure this tears down and lands in | ||
| /// `unavailable` so the mic does not appear hot after a failed start. | ||
| private func beginRecognition() { | ||
| guard let recognizer, recognizer.isAvailable else { | ||
| failStart() |
There was a problem hiding this comment.
Transient setup failures permanently disable the mic button
failStart() sets state to .unavailable, which the isAvailable doc describes as a terminal state for "permanently unavailable (unsupported locale, denied, or restricted)" scenarios. However, three paths that call failStart() are transient: recognizer.isAvailable == false (server recognition temporarily offline or on-device model not yet downloaded), format.channelCount == 0 (audio input route absent — can change when the user connects headphones), and audioEngine.start() throwing (transient system resource contention). After any of these, isAvailable returns false and there is no code path back from .unavailable to .idle, so the button stays greyed out until the user restarts the app. These three paths should return to .idle (not .unavailable) so the user can retry after the transient condition clears.
| /// On-device voice dictation for the field. Owned here so its lifecycle is | ||
| /// the composer's: it is torn down on send, focus loss, `onDisappear`, and a |
There was a problem hiding this comment.
New
ObservableObject/@StateObject where @Observable/@State is the required cmux shape
ComposerDictationController is a new cmux-owned ObservableObject with @Published state, stored with @StateObject. The cmux SwiftUI state rule flags exactly this pattern for new code: @Observable + @State (or value snapshots) is the required shape. Using ObservableObject causes the whole TerminalComposerView body to invalidate on every state transition — even state changes like .requestingPermission → .listening that affect only the mic button. Converting to @Observable would scope invalidation to the views that actually read the changed property.
The same pattern appears in ComposerDictationController.swift at the class declaration.
Rule Used: Flag SwiftUI changes that can cause stale state, b... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…on, l10n FINDING 1: ComposerDictationController used ObservableObject/@published without importing Combine (file-scoped imports), failing iOS type-check. Migrate to the @observable macro to match the codebase convention (import Observation, drop the protocol and @published); hold it in TerminalComposerView with @State so SwiftUI tracks the observed state automatically. FINDING 2: a second tap during .requestingPermission fell through to start(), which canStart rejected, so the pending permission callback later started the mic anyway. Make .requestingPermission cancellable: toggle now aborts the pending start (back to idle, callback dropped), and the permission callback short-circuits unless still .requestingPermission for both the granted and denied paths, so a cancel never starts the engine or clobbers idle. A later tap starts normally. Modeled as canCancelPendingStart on the pure state enum. FINDING 3: add mobile.composer.mic.start / mobile.composer.mic.stop to Localizable.xcstrings with en + ja values. Extend host-testable state-machine tests for the cancel transition. Bump the TerminalComposerView file-length budget for two doc-comment lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the three autoreview findings (215050b): F1 (compile failure): Migrated F2 (cancel race): F3 (localization): Added Tests: extended the host-testable state-machine tests for the cancel transition. The package cannot host-build here (iOS-only GhosttyKit binary dep), so the iOS compile + tests rely on CI ios-simulator. Budget guard green (bumped TerminalComposerView budget by 2 for the |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift`:
- Around line 63-67: The mic button should allow canceling a pending permission
request when tapped a second time while in the `.requestingPermission` state,
consistent with the documented behavior in the isAvailable property. Currently,
the toggle logic (around lines 75-80) only calls stop() when the state is
`.listening`, but it should also call stop() when the state is
`.requestingPermission` so that a second tap properly cancels the in-flight
permission request instead of routing back to start() which no-ops at line 87.
Update the condition that determines when to call stop() to include both the
`.listening` and `.requestingPermission` states.
- Around line 163-166: The failStart() method unconditionally sets state to
.unavailable, which prevents any retry attempts. Only permanent failures
(unsupported locale and denied/restricted authorization) should set state to
.unavailable; transient failures should allow retry by returning to .idle.
Modify the callers of failStart() at lines 165 (recognizer unavailability in
beginRecognition), 183 (audio session error), 192 (missing input route), and 205
(engine startup failure) to set state = .idle instead of calling failStart(),
reserving the permanent .unavailable state only for permanent conditions like
unsupported locale and authorization denials. Alternatively, refactor
failStart() to accept a parameter indicating whether the failure is permanent or
transient, then conditionally set either .unavailable or .idle accordingly.
🪄 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: 5ae3e37c-12b3-4410-9718-7e2a5e86b1fd
⛔ Files ignored due to path filters (1)
.github/swift-file-length-budget.tsvis excluded by!**/*.tsv
📒 Files selected for processing (6)
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationTextMerge.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swiftPackages/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/ComposerDictationTests.swiftios/Config/Info.plistios/cmux/Resources/InfoPlist.xcstrings
| private func beginRecognition() { | ||
| guard let recognizer, recognizer.isAvailable else { | ||
| failStart() | ||
| return |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n -C2 "failStart\\(|state = \\.unavailable|recognizer\\.isAvailable|channelCount > 0|setCategory\\(|audioEngine\\.start\\(" Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftRepository: manaflow-ai/cmux
Length of output: 1906
🏁 Script executed:
#!/bin/bash
# Get the full state enum definition and key transitions
rg -n -B5 -A10 "enum.*State|case idle|case unavailable" Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift | head -60Repository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
#!/bin/bash
# Get context around lines 57-60, 87-90, and 99-102 to understand the permanent vs transient distinction
sed -n '50,105p' Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftRepository: manaflow-ai/cmux
Length of output: 2442
🏁 Script executed:
#!/bin/bash
# Check if there's any retry mechanism or state reset elsewhere in the file
rg -n "state = \\.idle|\.idle" Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftRepository: manaflow-ai/cmux
Length of output: 163
🏁 Script executed:
#!/bin/bash
# Get the full State enum definition and canStart/isListening logic
rg -n -B3 -A15 "enum ComposerDictationState|var canStart|var isListening" Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftRepository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
#!/bin/bash
# Get the stop() method and any other state resets to understand lifecycle
sed -n '115,130p' Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftRepository: manaflow-ai/cmux
Length of output: 784
🏁 Script executed:
#!/bin/bash
# Check what recognizer.isAvailable represents (line 164 guard)
rg -n "isAvailable" Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftRepository: manaflow-ai/cmux
Length of output: 179
🏁 Script executed:
#!/bin/bash
# Find the ComposerDictationState enum definition
rg -n "enum ComposerDictationState" -A 10 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftRepository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
#!/bin/bash
# Get the full file size and structure to understand it better
wc -l Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftRepository: manaflow-ai/cmux
Length of output: 151
🏁 Script executed:
#!/bin/bash
# Get the complete initialization and state enum near the top
sed -n '1,50p' Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftRepository: manaflow-ai/cmux
Length of output: 2562
🏁 Script executed:
#!/bin/bash
# Find where ComposerDictationState is defined
find Packages/CmuxMobileShellUI -name "*.swift" -exec grep -l "enum ComposerDictationState" {} \;Repository: manaflow-ai/cmux
Length of output: 146
🏁 Script executed:
#!/bin/bash
# Check the end of the file to see if enum is defined there
tail -100 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftRepository: manaflow-ai/cmux
Length of output: 4144
🏁 Script executed:
#!/antml:thinking
Let me also look for what the canStart property is, since that's referenced in line 87 `guard state.canStart else`
</thinking>
# Find canStart definition
rg -n "var canStart" Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftRepository: manaflow-ai/cmux
Length of output: 129
🏁 Script executed:
#!/bin/bash
# Check the ComposerDictationState file
cat Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationTextMerge.swiftRepository: manaflow-ai/cmux
Length of output: 3463
🏁 Script executed:
#!/bin/bash
# Search for ComposerDictationState definition
fd ComposerDictationState Packages/CmuxMobileShellUI/Sources/Repository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
#!/bin/bash
# Find all Swift files in the directory
ls -la Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Repository: manaflow-ai/cmux
Length of output: 5804
Do not permanently disable dictation after transient startup failures.
failStart() at line 240 unconditionally sets state = .unavailable, but its callers include transient conditions: audio session setup errors (line 183), missing input route (line 192), engine startup failure (line 205), and recognizer unavailability (line 165). Once .unavailable, canStart returns false indefinitely, blocking any retry.
The state machine design already distinguishes permanent failures—stop() explicitly preserves .unavailable as terminal while returning to .idle for other cases. Permanent conditions (unsupported locale at line 59, denied/restricted authorization at line 101) should set .unavailable; transient setup failures should return to .idle to allow retry.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift`
around lines 163 - 166, The failStart() method unconditionally sets state to
.unavailable, which prevents any retry attempts. Only permanent failures
(unsupported locale and denied/restricted authorization) should set state to
.unavailable; transient failures should allow retry by returning to .idle.
Modify the callers of failStart() at lines 165 (recognizer unavailability in
beginRecognition), 183 (audio session error), 192 (missing input route), and 205
(engine startup failure) to set state = .idle instead of calling failStart(),
reserving the permanent .unavailable state only for permanent conditions like
unsupported locale and authorization denials. Alternatively, refactor
failStart() to accept a parameter indicating whether the failure is permanent or
transient, then conditionally set either .unavailable or .idle accordingly.
There was a problem hiding this comment.
♻️ Duplicate comments (1)
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift (1)
263-266:⚠️ Potential issue | 🟠 Major | ⚡ Quick winTreat setup-time start failures as retryable, not terminal.
Line 265 unconditionally sets
.unavailable, but callers include transient start failures from Line 187, Line 206, Line 215, and Line 228. That makes one temporary audio/session/route failure permanently disable dictation for this controller instance. Keep.unavailableonly for permanent conditions (unsupported recognizer / denied permission), and return to.idlefor transient start failures.Suggested fix
- /// Tear down after a setup failure and disable the mic. Distinct from a clean - /// stop because a failed start indicates the recognizer cannot be used right - /// now (no input route, session error, recognizer offline). + /// Tear down after a setup failure and return to idle so the user can retry. + /// Permanent unavailability (unsupported locale / denied permission) is set + /// explicitly at those decision points. private func failStart() { teardown() - state = .unavailable + state = .idle }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift` around lines 263 - 266, The failStart() method unconditionally sets state to .unavailable, but it is called for transient start failures that should be retryable rather than permanently disabling dictation. Modify the failStart() method to set state to .idle instead of .unavailable so that temporary audio, session, or route failures do not permanently disable the controller. Keep .unavailable only for permanent conditions like unsupported recognizer or denied permission, which should be handled separately and not through the failStart() method.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift`:
- Around line 263-266: The failStart() method unconditionally sets state to
.unavailable, but it is called for transient start failures that should be
retryable rather than permanently disabling dictation. Modify the failStart()
method to set state to .idle instead of .unavailable so that temporary audio,
session, or route failures do not permanently disable the controller. Keep
.unavailable only for permanent conditions like unsupported recognizer or denied
permission, which should be handled separately and not through the failStart()
method.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 5be89681-b786-4e53-b42b-2b342161e766
⛔ Files ignored due to path filters (1)
.github/swift-file-length-budget.tsvis excluded by!**/*.tsv
📒 Files selected for processing (5)
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationTextMerge.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swiftPackages/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/ComposerDictationTests.swiftios/cmux/Resources/Localizable.xcstrings
…te-away The shared stop path treated an intentional Stop/send the same as a cancel: teardown() cancelled the SFSpeechRecognitionTask before endAudio() and cleared onText, discarding buffered audio and the late FINAL result, so the last spoken words could be lost and a stale partial submitted. Split the two intents: - stop() now finalizes gracefully (Stop button, pre-send, focus loss): move to .stopping, flush buffered audio via endAudio(), stop the engine + remove the tap + deactivate the session, but keep the task and onText alive so the final result refines the committed text, then finishGraceful() cleans up. A 2.5s watchdog force-finishes so the controller cannot hang in .stopping. - cancel() keeps the immediate hard teardown for onDisappear and terminal switch, where losing the unrecognized tail is acceptable. Send preserves text because every partial already wrote into terminalInputText, so the latest spoken words are committed before submitComposer() reads them; the async final result only refines text already sent. Call sites: Stop button + send + focus loss -> stop(); onDisappear + terminal switch -> cancel(). Cancellable .requestingPermission behavior preserved (a graceful stop from a non-listening state falls back to cancel). Add pure-state coverage (canFinalize, isStopping) and bump the length budget for the touched composer view. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Fixed the P2 (dropped final words) finding. Split the shared stop path into two intents:
Send preserves the text because every partial already wrote into Call sites: Stop/send/focus-loss -> Extended the pure-state tests ( |
The send path called the async graceful stop() then immediately ran submitComposer(), which snapshots terminalInputText synchronously before any await. A late final speech result could land after the snapshot, dropping the finalized tail from the sent message and writing it back as a new draft in the just-cleared field. Switch send to cancel(): it immediately tears down the recognition task and drops onText, so the snapshot captures the current field text and no late callback can fire. Every partial already wrote the latest spoken words into terminalInputText, so nothing is lost. cancel() on an idle controller is a no-op, leaving the no-dictation send unchanged. The graceful stop() stays on the Stop button and focus-loss paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Fixed P1: send now hard-cancels dictation instead of the graceful async stop.
Now Graceful Budget: bumped the TSV for TerminalComposerView (+8 lines, comment), guard green. Host tests not runnable locally (package depends on the |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift`:
- Around line 425-428: The TerminalComposerView file exceeds 800 lines and
violates the Swift size guardrail policy. Extract a cohesive slice of
functionality—such as the dictation wiring (including the dictation.stop() call
and related dictation management logic) or attachment staging—into a separate
type or file. This will reduce the file size below 800 lines while improving
separation of concerns and maintainability. Ensure all dictation-related
methods, properties, and state are moved together to maintain cohesion in the
new extracted type.
🪄 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: 316f4ec2-ef73-4a66-bd3e-225d76560b95
⛔ Files ignored due to path filters (1)
.github/swift-file-length-budget.tsvis excluded by!**/*.tsv
📒 Files selected for processing (4)
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationTextMerge.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swiftPackages/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/ComposerDictationTests.swift
| // Stop dictation gracefully before sending. Every partial already wrote | ||
| // into `terminalInputText`, so the latest spoken words are committed and | ||
| // read by `submitComposer()` below. | ||
| dictation.stop() |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Split TerminalComposerView to satisfy the production Swift size guardrail.
This file is already over 800 lines and continues to absorb behavior. Please extract a cohesive slice (for example, dictation wiring or attachment staging) into separate types/files so this view drops below the policy threshold and remains reviewable.
As per coding guidelines, “Flag Swift production files that exceed 400 lines without a clear single responsibility, or exceed 800 lines even with mostly coherent responsibility.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift`
around lines 425 - 428, The TerminalComposerView file exceeds 800 lines and
violates the Swift size guardrail policy. Extract a cohesive slice of
functionality—such as the dictation wiring (including the dictation.stop() call
and related dictation management logic) or attachment staging—into a separate
type or file. This will reduce the file size below 800 lines while improving
separation of concerns and maintainability. Ensure all dictation-related
methods, properties, and state are moved together to maintain cohesion in the
new extracted type.
Source: Coding guidelines
`.duckOthers` is not a valid option for the `.record` category (only Ambient/PlayAndRecord/Playback/MultiRoute), and `.notifyOthersOnDeactivation` is only valid on deactivation. On OSes that enforce these documented restrictions, setCategory/setActive threw, the start path hit failStart(), and the mic was permanently disabled after the first tap. Use `.record` with `.measurement` mode and no options, and activate with a plain setActive(true). The deactivation call keeps `.notifyOthersOnDeactivation`, which is its correct use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Fixed P1 (invalid AVAudioSession options disabling dictation). The start path set Now:
No other invalid category/option combos remain. State machine, graceful-stop vs hard-cancel split, @observable, cancellable requestingPermission, and send-path hard-cancel are unchanged. This is an AVFoundation-config change with no host-testable seam; the ios-simulator CI is the compile gate. Swift file-length budget green. Commit 87184da. |
There was a problem hiding this comment.
♻️ Duplicate comments (2)
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift (1)
1-810: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy liftFile exceeds 800-line threshold.
The file is 810 lines and continues to grow with new features. The past review correctly identified that a cohesive slice (e.g., dictation wiring or attachment staging) should be extracted.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift` around lines 1 - 810, The TerminalComposerView file has grown to 810 lines and exceeds the recommended threshold for maintainability. Extract the image attachment handling logic into a separate file to reduce complexity and improve modularity. Specifically, move the attachment-related functions and types: `stagePickedItems`, `prepare`, `boundedSendPayload`, `downsampledImageData`, the `PreparedAttachment` struct, the `ImportedImageFile` struct, the `StagingTaskBox` class, the `AttachmentThumbnailCache` class, and the `AttachmentChip` view into a new file (e.g., ComposerAttachmentView.swift or similar). Keep the core `TerminalComposerView` and its main composition logic in the original file, and import the extracted types where needed to maintain the existing public interface and behavior.Source: Coding guidelines
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift (1)
326-329:⚠️ Potential issue | 🟠 Major | ⚡ Quick winTransient startup failures should not permanently disable dictation.
failStart()unconditionally setsstate = .unavailable, but several callers represent transient conditions: audio session setup errors (line 262), missing input route (line 271), engine startup failure (line 284), and recognizer temporarily unavailable (line 239). Once.unavailable,canStartreturns false indefinitely with no retry path.Permanent conditions (nil recognizer at init, denied/restricted authorization) correctly land in
.unavailable. Transient setup failures should return to.idleso the user can retry.Suggested approach
+ /// Tear down after a transient setup failure and allow retry. + private func failStartTransient() { + teardown() + state = .idle + } + /// Tear down after a setup failure and disable the mic. Distinct from a clean - /// stop because a failed start indicates the recognizer cannot be used right - /// now (no input route, session error, recognizer offline). + /// stop because a failed start indicates the recognizer cannot be used + /// (unsupported locale, denied/restricted authorization). private func failStart() { teardown() state = .unavailable }Then update callers:
- Line 239 (
recognizer.isAvailable):failStartTransient()— recognizer can become available again- Line 262 (session error):
failStartTransient()— another app may release audio- Line 271 (no input route):
failStartTransient()— headphones may reconnect- Line 284 (engine start):
failStartTransient()— resource contention may resolve🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift` around lines 326 - 329, The failStart() method unconditionally sets state to .unavailable, but several callers represent transient failures that should allow retry. Create a new method failStartTransient() that calls teardown() and sets state to .idle instead of .unavailable. Then update the four callers to use failStartTransient() instead of failStart(): the recognizer.isAvailable check, the audio session error handler, the missing input route detection, and the engine startup failure handler. This allows transient conditions to be retried while keeping permanent failures (nil recognizer, denied authorization) in the .unavailable state via the original failStart() method.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift`:
- Around line 326-329: The failStart() method unconditionally sets state to
.unavailable, but several callers represent transient failures that should allow
retry. Create a new method failStartTransient() that calls teardown() and sets
state to .idle instead of .unavailable. Then update the four callers to use
failStartTransient() instead of failStart(): the recognizer.isAvailable check,
the audio session error handler, the missing input route detection, and the
engine startup failure handler. This allows transient conditions to be retried
while keeping permanent failures (nil recognizer, denied authorization) in the
.unavailable state via the original failStart() method.
In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift`:
- Around line 1-810: The TerminalComposerView file has grown to 810 lines and
exceeds the recommended threshold for maintainability. Extract the image
attachment handling logic into a separate file to reduce complexity and improve
modularity. Specifically, move the attachment-related functions and types:
`stagePickedItems`, `prepare`, `boundedSendPayload`, `downsampledImageData`, the
`PreparedAttachment` struct, the `ImportedImageFile` struct, the
`StagingTaskBox` class, the `AttachmentThumbnailCache` class, and the
`AttachmentChip` view into a new file (e.g., ComposerAttachmentView.swift or
similar). Keep the core `TerminalComposerView` and its main composition logic in
the original file, and import the extracted types where needed to maintain the
existing public interface and behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: e694f6d5-e63b-4517-9186-fda4859be1c6
📒 Files selected for processing (2)
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
…not clobbered Every recognition callback rewrote the composer text as base + transcript, but the field stayed editable while listening/stopping, so any edit the user made after dictation started (a correction, a typed suffix) was silently discarded by the next partial or final callback. Lock the field while dictation owns the text: add ComposerDictationState.locksComposerField (.listening or .stopping), surface it on the controller, and bind the TextField's .disabled to it. The user cannot make an edit that would be clobbered; the field stays visible showing the live transcript and re-enables once dictation settles to idle. The mic toggle and send buttons sit outside the field and stay live; send hard-cancels dictation back to idle, re-enabling editing and refocusing. Guard the focus-loss finalize: disabling a focused field makes SwiftUI resign first responder, so skip the focus-loss stop() when the field is locked, otherwise dictation would stop itself the instant it started. Host-tested coverage for the lock predicate. Budget bumped for the TerminalComposerView growth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Fixed the P2 (dictation overwrites user edits) in 7c589c4. Approach: lock, not merge. While dictation owns the text (
Preserved from prior rounds: @observable controller, graceful-stop vs hard-cancel, cancellable .requestingPermission, send-path hard-cancel, valid AVAudioSession config. Tests: added 4 host tests for the lock predicate ( |
# Conflicts: # .github/swift-file-length-budget.tsv
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5674550. Configure here.
| // Denied or restricted: a terminal rest state that disables the | ||
| // mic. The captured callback is dropped. | ||
| self.onText = nil | ||
| self.state = .unavailable |
There was a problem hiding this comment.
Unavailable mic never retries
High Severity
After a denied permission or a failed start, ComposerDictationController moves to unavailable and the mic button is disabled via isAvailable. Nothing transitions back to idle when the user later grants speech/mic access in Settings or when a transient setup error clears, so dictation can stay permanently off for that composer instance.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 5674550. Configure here.
| // started. Only a focus loss while the field is NOT locked is the user | ||
| // moving on, and only that should finalize. | ||
| if !focused, !dictation.locksComposerField { | ||
| dictation.stop() |
There was a problem hiding this comment.
Focus loss won't stop dictation
Medium Severity
Focus loss is supposed to call dictation.stop(), but while listening the field is disabled and locksComposerField is true, so the initial resign-first-responder focus change is ignored. After that the field usually stays unfocused, so tapping away no longer changes focus and dictation keeps capturing until the mic or send path runs.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5674550. Configure here.
package-conventions-lint scans the whole iOS package tree and flagged this pre-existing caseless-enum namespace (added in #6197, whose lint run was skipped). It is a deliberate pure, stateless text-merge factored out for host-testing; mark it as a sanctioned lint:allow exception so the iOS lint gate passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ComposerDictationTextMerge (landed in #6197) is a caseless static-member enum with no lint:allow marker, so package-conventions-lint (a required check) has been red on main and on every PR branched off it. It is a genuine stateless pure-function namespace with no instance state to own, so the sanctioned inline lint:allow escape hatch is the correct fix rather than reshaping it into an instantiated type. Unblocks this consolidation PR's required CI; the consolidation diff itself is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tring The caseless enum ComposerDictationTextMerge (added by #6197, on main) trips the namespace-enum convention lint, which scans the whole iOS tree and fails package-conventions-lint on every PR. Convert the pure base+transcript merge into a receiver-natural String extension method, mergingDictation(transcript:), per the linter's recommended pattern, and update the controller call site and host tests. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s) (#6222) * Consolidate workspace surface-list extraction into CmuxWorkspaces (no new package) Folds the per-workspace surface-list derivation from feat-workspace-surface-list-model into the EXISTING CmuxWorkspaces domain package instead of standing up a new top-level CmuxWorkspaceSurfaceList package. The owner rejected the per-sliver micro-packages; the extraction is good, so it lives in the workspace domain package. What moved (byte-identical logic): - WorkspaceSurfaceListModel (the @mainactor @observable derivation model: orderedPanelIds, focusedPanelId, representativePanelIdForWorkspaceManualUnread, effectiveSelectedPanelId, the tabIdsTo* pane queries, and the paneLayoutVersion reorder bump) and its WorkspaceSurfaceTreeReading seam protocol now live in Packages/CmuxWorkspaces/Sources/CmuxWorkspaces/SurfaceList/. Both files are byte-identical to the member branch; they only import Foundation/Observation, so CmuxWorkspaces's Package.swift needs no new dependency. - The 12 behavior tests move into CmuxWorkspacesTests (only the @testable import target changed from CmuxWorkspaceSurfaceList to CmuxWorkspaces). App-side seam unchanged in shape: Workspace+WorkspaceSurfaceTreeReading.swift conforms Workspace to the seam and Workspace holds the model (weak back-ref via the seam to avoid a retain cycle), with the legacy accessors as one-line forwards. Every `import CmuxWorkspaceSurfaceList` became `import CmuxWorkspaces` (removed in Workspace.swift, which already imports CmuxWorkspaces). No new top-level package: Packages/CmuxWorkspaceSurfaceList/ is deleted and its 6 pbxproj package-reference entries are removed. The pbxproj only gains the 4 source-file wiring entries for Workspace+WorkspaceSurfaceTreeReading.swift. Verified: scripts/lint-ios-package-conventions.sh adds zero new violations (the one pre-existing namespace-enum ERROR in CmuxMobileShellUI is untouched debt on main); swift build + swift test green in Packages/CmuxWorkspaces (32 tests, 4 suites). Budget for Sources/Workspace.swift ratcheted to 12978. Supersedes the standalone CmuxWorkspaceSurfaceList package PR from feat-workspace-surface-list-model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix pre-existing package-conventions-lint break on main ComposerDictationTextMerge (landed in #6197) is a caseless static-member enum with no lint:allow marker, so package-conventions-lint (a required check) has been red on main and on every PR branched off it. It is a genuine stateless pure-function namespace with no instance state to own, so the sanctioned inline lint:allow escape hatch is the correct fix rather than reshaping it into an instantiated type. Unblocks this consolidation PR's required CI; the consolidation diff itself is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
) * Consolidate debug extractions into CmuxFeedback + CmuxAppKitSupportUI (no new packages) Folds two per-sliver refactor branches into existing domain packages so the debug-group extractions land without creating any new top-level package. 1. feat-mobile-host-rpc-router extracted the privileged Mac<->phone dogfood feedback sink into a new CmuxDogfoodFeedbackSink package. That domain folds into the existing CmuxFeedback package under DogfoodSink/: DogfoodFeedbackLimits, DogfoodFeedbackOutcome, DogfoodFeedbackSubmission (Sendable value types) and the nonisolated Sendable DogfoodFeedbackService. Byte-identical logic (same caps, base64-char-cap-before-decode then byte-cap ordering, Task.detached(.utility) off-main write, ISO8601-colons-to-dash bundle naming, 0700/0600 perms, bundle.json schema/sorted-keys/pretty, lexicographic prune keeping newest 50, and the same RPC error codes/messages). TerminalController.v2MobileDogfoodFeedbackSubmit is now a thin forward that resolves the authenticated email via the main-actor MobileHostService and calls service.submit(...); the service re-enforces the @manaflow.ai gate at the trust boundary. CmuxFeedback is already imported by TerminalController and already linked to the app target, so no import or pbxproj change was needed. 2. feat-debug-windows-extraction extracted the self-contained About-titlebar debug cluster into a new CmuxDebugWindowsUI package. That UI folds into the existing CmuxAppKitSupportUI package under AboutTitlebarDebug/: the AboutWindowKind / TitlebarVisibilityOption / TitlebarToolbarStyleOption value enums, AboutTitlebarDebugOptions value type, AboutTitlebarDebugStore (@mainactor @observable, single writer), AboutTitlebarDebugWindowController, AboutTitlebarDebugView, plus the DebugWindowsCoordinator and the WindowDecorating protocol seam. AppDelegate conforms to WindowDecorating and owns the coordinator (held weakly by the coordinator/store to avoid a retain cycle); cmuxApp.swift and the About/Acknowledgments controllers forward into the app-owned coordinator/store. Byte-identical window identifiers, titles, style-mask bits, toolbar identifiers, sizes, and copy-config payload. CmuxAppKitSupportUI already exists and is already linked to the app target. Both member branches branched off an older main (5321bec), before CmuxFeedback and CmuxAppKitSupportUI existed, which is why they created standalone packages. Neither new package is created here; zero new top-level packages and zero pbxproj entries were added. Verification: scripts/lint-ios-package-conventions.sh introduces no new violation and zero lint:allow (the single pre-existing ERROR is ComposerDictationTextMerge in CmuxMobileShellUI, unrelated to this change). swift build + swift test green in both packages (CmuxFeedback 12 tests, CmuxAppKitSupportUI 8 tests). File-length budget reconciled: TerminalController 14829->14681, cmuxApp 4921->4516, AppDelegate 17894->17905 (+11 for composition-root wiring). Supersedes the per-sliver package PRs from feat-mobile-host-rpc-router and feat-debug-windows-extraction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix package-conventions-lint: scope ComposerDictationTextMerge onto String The caseless enum ComposerDictationTextMerge (added by #6197, on main) trips the namespace-enum convention lint, which scans the whole iOS tree and fails package-conventions-lint on every PR. Convert the pure base+transcript merge into a receiver-natural String extension method, mergingDictation(transcript:), per the linter's recommended pattern, and update the controller call site and host tests. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * AboutTitlebarDebugStore: split config snapshot from pasteboard write The extracted store is now public package code with a unit test. Splitting the pure configSnapshot() from copyConfigToPasteboard() lets the test assert the payload without clearing the real NSPasteboard.general on the dev/CI process. No behavior change to the menu action. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>


What
Adds a microphone button to the iOS composer that does on-device voice dictation. Tap the mic, speak, and the transcription fills the composer text field live. Tap again (or tap send, or leave the field) to stop. The text is then sent as a normal message. This dictates INTO the textbox; it does not send audio.
How to use
A mic button (
MobileComposerMic) sits beside the paperclip attach button. Tap to start; it turns into a pulsing redmic.fillwhile listening. As you speak, partial transcriptions stream into the field, appended after whatever you already typed. Tap the mic again, tap send, move focus off the field, switch terminals, or leave the composer to stop.Permissions
NSMicrophoneUsageDescriptionandNSSpeechRecognitionUsageDescriptionadded toios/Config/Info.plist.ios/cmux/Resources/InfoPlist.xcstringswith English and Japanese values (minimal textual insertion, file stays valid JSON).SFSpeechRecognizer.requestAuthorizationplus microphone permission (AVAudioApplication.requestRecordPermissionon iOS 17+,AVAudioSession.requestRecordPermissionfallback). Denied/restricted/unsupported lands in a terminalunavailablestate that disables the button. No crash.On-device vs server recognition
Prefers on-device recognition for privacy and offline use: sets
requiresOnDeviceRecognition = truewhensupportsOnDeviceRecognition, falling back to server recognition only when the device cannot recognize locally.Design
ComposerDictationControlleris an@MainActorObservableObjectwrappingSFSpeechRecognizer+SFSpeechAudioBufferRecognitionRequestdriven by anAVAudioEnginetap. State machine:idle -> requestingPermission -> listening -> stopping -> idle, with a terminalunavailable. The SwiftUI view stays thin (a@StateObjectand a toggle).Text merge: on start the controller captures the composer's current text as the base. Every partial replaces the live tail, so the field always reads base + transcript and the pre-typed text is never clobbered. A single separating space is inserted between a non-whitespace base and the transcript; existing trailing whitespace is preserved (no doubling). Factored into a pure
ComposerDictationTextMergefor host testing.Lifecycle / teardown guarantees
stop()is idempotent and tears down fully: cancels the recognition task, ends and drops the request, removes the audio tap (removeTap(onBus:)), stops the engine, deactivates the audio session, and clears the callback. It is called from: a second mic tap, send, field focus loss, the view's.onDisappear, and.onChange(of: terminalID). A failed start (no input route, session error, recognizer offline) tears down and disables the mic rather than leaving it hot.Swift 6 concurrency
The recognition result handler extracts only Sendable value snapshots (the transcript
StringandisFinal/errorBools) before hopping to the main actor, so no non-Sendable reference (SFSpeechRecognitionResult,Error) crosses the actor boundary. The result closure capturesselfweakly (no retain cycle through the task). The realtime audio-tap closure only appends buffers to the request and touches no main-actor state.Tests
ComposerDictationTestscovers the text-merge (empty base, separating space, trailing-whitespace preservation, leading-transcript trim, empty/growing partials, verbatim base) and the state machine's purecanStart/isListeningtransitions. The Speech/AVFoundation engine wiring is iOS-only and exercised by the simulator build, not host-compilable here (the package depends on the iOS-only GhosttyKit binary).🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
Medium Risk
Touches microphone/speech permissions and real-time audio session teardown; send uses hard-cancel to avoid late recognition callbacks mutating the draft after submit.
Overview
Adds on-device voice dictation to the iOS message composer: a mic control beside attach streams live transcription into the draft (text only, not audio).
New
ComposerDictationController(Speech +AVAudioEngine) and host-testableComposerDictationState/ComposerDictationTextMergehandle permissions, prefer on-device recognition, merge partials as base + transcript without clobbering existing text, and distinguish graceful stop (finalize + 2.5s watchdog) from hard cancel (send, disappear, terminal switch).TerminalComposerViewwires the mic UI, disables the field while dictation owns the text, ignores focus-loss from that lock, and tears down dictation on lifecycle events. iOS adds microphone and speech recognition usage strings (EN/JA) plus localized mic accessibility labels;ComposerDictationTestscovers merge and state rules.Reviewed by Cursor Bugbot for commit 5674550. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Adds on-device voice dictation to the iOS composer. Tap the mic to speak and see live transcription; send hard‑cancels to prevent late results from changing what gets sent.
New Features
MobileComposerMicbeside attach; pulsing redmic.fillwhile listening.@MainActor@Observablecontroller with clean teardown and an “unavailable” state; addsNSMicrophoneUsageDescriptionandNSSpeechRecognitionUsageDescription(EN/JA) and localized “Start dictation”/“Stop dictation”; tests cover merge/state/lock.Bug Fixes
AVAudioSessionstart: use.record+.measurementwithout invalid options to prevent start failures.Written for commit 5674550. Summary will update on new commits.
Summary by CodeRabbit