Skip to content

Commit d6918b0

Browse files
fix(desktop): cycle listening through all three audio recording modes (#11640)
## Problem `AssistantSettings.AudioRecordingMode` has three cases — `off`, `always`, `onlyMeetings` — but the listening control could only reach two of them. `toggleListening` read: ```swift let nextMode = currentMode == .off ? .onlyMeetings : .off ``` so `.always` was **unreachable from the top bar and from Home**. Turning the microphone "on" always armed the meetings gate, which deliberately holds the mic shut until a call is detected; there was no way to ask for continuous recording from either shell. Reported from the top-bar button: it "only toggles between off and only meetings". Only Meetings was also indistinguishable from Always On at a glance — same `mic`, same dot — even though the two behave completely differently. ## Change - **A cycle, not a flip.** `CaptureListeningLogic.cycleListening` advances `Off → Always On → Only Meetings → Off`. Both shells already share this one function, so both gain the third mode. - **Only Meetings is marked.** It carries `person.2.fill` in the corner. The mark is *additive corner ink* like the state dot — the base `mic` silhouette stays byte-identical in every state, which is the rule the cluster's header sets and `ShellStatusIconLegibilityTests` measures in pixels. - **Only Meetings explains itself once.** Selecting it shows a popover — "the microphone stays closed until Omi detects a call, then records until it ends" — which auto-dismisses. It fires on the transition only, never on hover and never at rest, so the resting cluster stays wordless. - **The tooltip names the destination.** "Click to stop" was true of a two-state switch and is a false promise from Always On, where a click selects Only Meetings and stops nothing. ## Product invariants **INV-CHAT-1** (one shared transcript across surfaces) — cited because the diff touches `desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift`, which is inside its path globs. The change there is a single call-site rename (`toggleListening` → `cycleListening`); it does not touch conversation ownership, transcript routing, or any chat surface, so the invariant's guard tests are unchanged. ## Failure class Failure-Class: FC-split-mutation-authority The control is the authority for the listening mode's transitions, but it only ever authored two of the three states the mode declares, leaving the third reachable exclusively from Settings. The fix is the class's canonical prevention: one transition function owns the whole state machine, and `testTheCycleCoversEveryDeclaredMode` fails if a future mode is added that the control cannot select. ## Review follow-ups (second commit) Both findings from the automated review were valid and are fixed: **P1 — the microphone stayed open when Only Meetings could not yet prove a call.** `reconcileCapture`'s `guard meetingStateReady else { return }` returned *before* either pause branch. Selecting Only Meetings from a live Always session builds a fresh detector, so that first pass runs with `hasObservedState == false` and the microphone the previous mode opened kept running until the detector's first asynchronous probe landed. A gate the user selects in order to close the mic has to fail closed, so "not known yet" now means "not in a call". `MeetingGateReadinessPolicy` names the rule and `pauseCaptureWhileMeetingGateUnknown` takes the same stop the normal gating takes, earlier. This path only became reachable from the button in this PR: before it, Always On could not be selected outside Settings, so `Always → Only Meetings` was not a transition the control could make. **P2 — the tooltip promised a mode the click could not reach.** Without the microphone grant, a click spends itself on the permission prompt and the mode does not move. The tooltip now says what is actually missing until the grant exists. Line-Count-Exception: desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift | 1736 -> 1738 | Fail-closed privacy fix for P1. The pause helper and its policy live in new files (`AppState+MeetingGatePause.swift`, `MeetingGateReadinessPolicy`); the two lines added here are the guard's call and its comment, which have to sit at the point reconcileCapture returns early. ## Verification - `xcrun swift build -c debug --package-path Desktop --build-tests` — clean. - `ShellListeningCycleTests` 14/14, `ShellStatusIconLegibilityTests` 13/13, `DashboardCaptureStateTests` 10/10, `MeetingGatedSystemAudioTests` — **43 tests, 0 failures**. - **The regression test fails against the shipped behaviour.** Reverting `nextAudioRecordingMode` to the two-state flip fails 4 tests and reproduces the report verbatim: > the listening cycle ran off → onlyMeetings → off → onlyMeetings. It has to offer all three modes… - `make preflight` — all checks pass. ### What is *not* verified, explicitly The final code has **automated coverage only — it was not click-verified in a running build.** The full three-state cycle, the `person.2.fill` badge and the popover *were* exercised live (clicking the real control, reading `defaults` and screenshotting each state) on a build of the pre-rebase implementation, which carried the same badge, popover and cycle order against the older two-setting model. That evidence does not transfer to this diff. The blocker was environmental, not the change: the summoned shell places itself on this machine's secondary display, synthesized clicks are not delivered there (a control-tab click failed too), and the panel auto-dismisses on any focus change, so it could not be relocated to the primary display. Worth a reviewer clicking the button once before merge. ## Notes for review Three `ShellStatusTooltip.audio` call sites in `ShellStatusIconLegibilityTests` gained a `next:` argument. Two are mechanical — their assertions (`hasPrefix("Audio")`, `contains("Meetings only")`) are byte-identical. The third, `testTheAwaitingMeetingAudioTooltipDoesNotClaimOffOrStart`, changed one expected literal from `"Click to turn off"` to `"Click for Off"`: from Only Meetings a click still lands on Off, so the guard's intent is unchanged and its real assertion — `XCTAssertFalse(contains("Click to start"))` — is untouched. Only the copy it looks for moved. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents b062dec + f38339d commit d6918b0

10 files changed

Lines changed: 427 additions & 17 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Foundation
2+
3+
extension AppState {
4+
/// Pause both capture paths without ending the session, for the window in which the meeting gate
5+
/// has not answered yet. Mirrors the pause branches `reconcileCapture` uses once it does answer;
6+
/// this is the same stop, taken earlier, when the honest answer is "not in a call as far as we
7+
/// know".
8+
/// Apply `MeetingGateReadinessPolicy` and pause when it says the gate cannot yet justify capture.
9+
@MainActor
10+
func pauseCaptureIfMeetingGateUnknown(
11+
mode: AssistantSettings.AudioRecordingMode, meetingStateReady: Bool
12+
) {
13+
guard
14+
MeetingGateReadinessPolicy.shouldPauseCapture(
15+
mode: mode, meetingStateReady: meetingStateReady)
16+
else { return }
17+
pauseCaptureWhileMeetingGateUnknown()
18+
}
19+
20+
@MainActor
21+
func pauseCaptureWhileMeetingGateUnknown() {
22+
if let mic = audioCaptureService, mic.capturing {
23+
mic.stopCapture()
24+
AudioLevelMonitor.shared.updateMicrophoneLevel(0)
25+
log("Transcription: Microphone capture paused (meeting state not yet known)")
26+
}
27+
if #available(macOS 14.4, *) {
28+
if let systemService = systemAudioCaptureService as? SystemAudioCaptureService,
29+
systemService.capturing
30+
{
31+
systemService.stopCapture()
32+
AudioLevelMonitor.shared.updateSystemLevel(0)
33+
log("Transcription: System audio capture paused (meeting state not yet known)")
34+
}
35+
}
36+
}
37+
}

desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,8 @@ extension AppState {
616616
isAwaitingMeeting = mode == .onlyMeetings && !meetingActive
617617

618618
guard meetingStateReady else {
619+
// Fail closed while the gate has not answered — see `pauseCaptureWhileMeetingGateUnknown`.
620+
pauseCaptureIfMeetingGateUnknown(mode: mode, meetingStateReady: meetingStateReady)
619621
log("Transcription: waiting for meeting detector before changing capture state")
620622
return
621623
}

desktop/macos/Desktop/Sources/AppState/MeetingConversationBoundaryPolicy.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import Foundation
22

3+
/// Whether capture may continue while the meeting gate has not yet answered.
4+
///
5+
/// Only Meetings is a *closed* gate that a detected call opens, so "we do not know yet" has to be
6+
/// treated as "not in a call". Selecting Only Meetings from a live Always session builds a fresh
7+
/// detector, so its first reconcile pass runs with `hasObservedState == false`; leaving capture
8+
/// alone until the first probe lands would keep the microphone the previous mode opened running
9+
/// after the user asked for it to be closed.
10+
enum MeetingGateReadinessPolicy {
11+
static func shouldPauseCapture(
12+
mode: AssistantSettings.AudioRecordingMode, meetingStateReady: Bool
13+
) -> Bool {
14+
mode == .onlyMeetings && !meetingStateReady
15+
}
16+
}
17+
318
enum MeetingConversationBoundaryPolicy {
419
typealias Role = TranscriptionConversationRole
520

desktop/macos/Desktop/Sources/MainWindow/CaptureListeningLogic.swift

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,50 @@ enum CaptureListeningLogic {
6262
}
6363
}
6464

65+
/// Off → Always On → Only Meetings → Off.
66+
///
67+
/// `AudioRecordingMode` has three cases, but the control only ever reached two of them: it
68+
/// flipped between `.off` and `.onlyMeetings`, so `.always` — the mode that actually records
69+
/// continuously — could not be selected from the top bar or Home at all, and turning the
70+
/// microphone "on" silently armed a gate that keeps it shut until a call starts.
71+
static func nextAudioRecordingMode(after mode: AssistantSettings.AudioRecordingMode)
72+
-> AssistantSettings.AudioRecordingMode
73+
{
74+
switch mode {
75+
case .off: return .always
76+
case .always: return .onlyMeetings
77+
case .onlyMeetings: return .off
78+
}
79+
}
80+
81+
/// The name of a *mode*, for naming a state the session is not in yet.
82+
///
83+
/// Distinct from `listeningModeTitle`, which describes the **running** session and may answer
84+
/// with the live microphone's own name ("Ray-Ban Meta") or with "In Meeting". That is the right
85+
/// answer for "what is happening now" and the wrong one for "what does this click select".
86+
static func audioRecordingModeTitle(_ mode: AssistantSettings.AudioRecordingMode) -> String {
87+
switch mode {
88+
case .off: return "Off"
89+
case .always: return "Always On"
90+
case .onlyMeetings: return "Only Meetings"
91+
}
92+
}
93+
6594
// MARK: Actions
6695

67-
static func toggleListening(
96+
/// Advance the control one step. Returns the mode it landed on so a caller can react to the
97+
/// transition itself, or `nil` when the click was spent on the permission prompt and the state
98+
/// did not move.
99+
@discardableResult
100+
static func cycleListening(
68101
appState: AppState, audioRecordingModeRaw: Binding<String>, isTogglingListening: Binding<Bool>
69-
) {
102+
) -> AssistantSettings.AudioRecordingMode? {
70103
let currentMode = audioRecordingMode(raw: audioRecordingModeRaw.wrappedValue)
71-
let nextMode: AssistantSettings.AudioRecordingMode = currentMode == .off ? .onlyMeetings : .off
104+
let nextMode = nextAudioRecordingMode(after: currentMode)
72105
let enabled = nextMode != .off
73106
if enabled && !appState.hasMicrophonePermission {
74107
appState.requestMicrophonePermission()
75-
return
108+
return nil
76109
}
77110

78111
isTogglingListening.wrappedValue = true
@@ -82,6 +115,7 @@ enum CaptureListeningLogic {
82115
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
83116
isTogglingListening.wrappedValue = false
84117
}
118+
return nextMode
85119
}
86120

87121
static func toggleCapture(

desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1799,7 +1799,7 @@ struct DashboardPage: View {
17991799
}
18001800

18011801
private func toggleListening() {
1802-
CaptureListeningLogic.toggleListening(
1802+
CaptureListeningLogic.cycleListening(
18031803
appState: appState, audioRecordingModeRaw: $audioRecordingModeRaw,
18041804
isTogglingListening: $isTogglingListening)
18051805
}

desktop/macos/Desktop/Sources/MainWindow/QueryShell/ShellStatusIcons.swift

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,28 @@ enum ShellStatusGlyph {
204204
/// pair measured **while listening**, when the mic became `waveform`; that is the state the reported
205205
/// screenshot was in, and it is fixed by `listening` above rather than by this line.
206206
static let screen = "display"
207+
208+
/// The corner mark the listening control wears while its mode is Only Meetings.
209+
///
210+
/// **This is not the swap the header rejects.** `listening` above is still the only listening
211+
/// silhouette and is still byte-identical in every state; this rides *outside* it, in the corner,
212+
/// exactly as `ShellStatusDot` does. The header's distinction is between ink that *replaces* the
213+
/// glyph and ink that is *added* to it, and both marks this control wears are the second kind.
214+
///
215+
/// **It is also not a new vocabulary.** `person.2.fill` is already the mark Home's listening
216+
/// control uses for this mode, so one mode reads the same on both shells rather than growing a
217+
/// second symbol for one idea.
218+
static let meetingsOnly = "person.2.fill"
219+
220+
/// Which modes wear a mark, as a function rather than a ternary inside a `body`, so the rule —
221+
/// *exactly one* of the three modes is badged — is something a test can state directly instead of
222+
/// scraping it back out of the view.
223+
///
224+
/// `.always` and `.off` are deliberately unbadged: the dot and the slash already say everything
225+
/// true about them, and a mark on every state is a mark that distinguishes nothing.
226+
static func modeBadge(for mode: AssistantSettings.AudioRecordingMode) -> String? {
227+
mode == .onlyMeetings ? meetingsOnly : nil
228+
}
207229
}
208230

209231
// MARK: - The sentence
@@ -226,17 +248,30 @@ enum ShellStatusTooltip {
226248
/// `isAwaitingMeeting` is the Only Meetings wait: the session is armed, the mic is paused, and a
227249
/// click turns listening *off* rather than starting it. That must not reuse the "off / click to
228250
/// start" sentence.
229-
static func audio(state: HomeStatusState, mode: String, isAwaitingMeeting: Bool = false) -> String {
251+
/// `next` names the mode a click moves to. It is a parameter rather than the fixed "Click to
252+
/// stop" / "Click to start" this shipped with, because the control is a three-mode cycle: from
253+
/// Always On a click does not stop anything, it selects Only Meetings, and a tooltip on a
254+
/// wordless control is the only place that promise is written down. Naming the destination also
255+
/// makes the third mode discoverable without clicking twice to find it.
256+
static func audio(
257+
state: HomeStatusState, mode: String, isAwaitingMeeting: Bool = false, next: String,
258+
hasMicrophonePermission: Bool = true
259+
) -> String {
230260
switch state {
231261
case .blocked:
232262
return "Audio — transcription unavailable. Open Settings to reconnect."
233263
case .active:
234-
return "Audio — listening (\(mode)). Click to stop."
264+
return "Audio — listening (\(mode)). Click for \(next)."
235265
case .inactive:
266+
// Without the microphone grant a click spends itself on the permission prompt and the mode
267+
// does not move, so promising the next mode here would be a promise the click cannot keep.
268+
guard hasMicrophonePermission else {
269+
return "Audio — off. Omi needs microphone access. Click to grant it."
270+
}
236271
if isAwaitingMeeting {
237-
return "Audio — waiting for a call (\(mode)). Nothing is being transcribed. Click to turn off."
272+
return "Audio — waiting for a call (\(mode)). Nothing is being transcribed. Click for \(next)."
238273
}
239-
return "Audio — off. Nothing is being transcribed. Click to start."
274+
return "Audio — off. Nothing is being transcribed. Click for \(next)."
240275
}
241276
}
242277

@@ -273,6 +308,16 @@ struct ShellStatusIconButton: View {
273308
/// is exact only while this flag moves nothing else. Product code leaves it alone and passes
274309
/// `state: nil` for a control that has no badge to draw.
275310
var showsDot: Bool = true
311+
/// A corner mark naming the *mode* a capability is running in, for a control that has more than
312+
/// one. `nil` on every control that does not — screen capture has no modes, and a mark it never
313+
/// wears is not a mark it should be able to draw.
314+
///
315+
/// It follows `showsDot`'s contract, not the glyph's: additive ink outside the silhouette, so
316+
/// `ShellStatusGlyph.listening` stays byte-identical across every state and the swap guard in
317+
/// `ShellStatusIconLegibilityTests` still measures what it was written to measure. It sits on the
318+
/// leading edge because the dot owns the trailing corner, and the two answer different questions —
319+
/// the dot whether it is capturing, this one which mode it is set to.
320+
var badge: String? = nil
276321
var isSelected: Bool = false
277322
let action: () -> Void
278323

@@ -305,6 +350,13 @@ struct ShellStatusIconButton: View {
305350
ShellStatusDot(state: state).offset(x: 6, y: -5)
306351
}
307352
}
353+
.overlay(alignment: .bottomLeading) {
354+
if let badge {
355+
Image(systemName: badge)
356+
.scaledFont(size: OmiType.micro, weight: .bold)
357+
.offset(x: -5, y: 4)
358+
}
359+
}
308360
}
309361
.buttonStyle(GlassIconButtonStyle(isActive: isSelected || isActive))
310362
.help(tooltip)
@@ -326,6 +378,12 @@ struct ShellStatusIcons: View {
326378
@State private var isCaptureMonitoring = false
327379
@State private var isTogglingCapture = false
328380
@State private var isTogglingListening = false
381+
/// Shown for a beat when a click *selects* Only Meetings, never on hover and never at rest.
382+
/// Only Meetings is the one mode whose behaviour no glyph can state — the control is switched
383+
/// on while the microphone is deliberately held shut until a call starts — so selecting it is
384+
/// the one moment that earns a sentence. Tying it to the transition keeps the cluster wordless.
385+
@State private var showsMeetingsHint = false
386+
@State private var meetingsHintDismissal: DispatchWorkItem?
329387

330388
@AppStorage("screenAnalysisEnabled") private var screenAnalysisEnabled = true
331389
@AppStorage(AssistantSettings.audioRecordingModeDefaultsKey) private var audioRecordingModeRaw =
@@ -338,9 +396,18 @@ struct ShellStatusIcons: View {
338396
tooltip: listeningTooltip,
339397
state: listeningState,
340398
isBusy: isTogglingListening,
341-
action: toggleListening
399+
badge: ShellStatusGlyph.modeBadge(for: listeningMode),
400+
action: cycleListening
342401
)
343402
.accessibilityIdentifier("shell-status-listening")
403+
.popover(isPresented: $showsMeetingsHint, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) {
404+
Text(Self.meetingsHint)
405+
.scaledFont(size: OmiType.caption)
406+
.foregroundStyle(Ink.primary)
407+
.multilineTextAlignment(.leading)
408+
.frame(width: 232, alignment: .leading)
409+
.padding(OmiSpacing.sm)
410+
}
344411

345412
ShellStatusIconButton(
346413
systemImage: ShellStatusGlyph.screen,
@@ -366,12 +433,27 @@ struct ShellStatusIcons: View {
366433
CaptureListeningLogic.listeningStatus(appState: appState)
367434
}
368435

436+
/// What the control is *set to* — distinct from `listeningState`, which is whether it is
437+
/// currently capturing. Only Meetings is exactly the case where those two disagree, so the
438+
/// button needs both.
439+
private var listeningMode: AssistantSettings.AudioRecordingMode {
440+
CaptureListeningLogic.audioRecordingMode(raw: audioRecordingModeRaw)
441+
}
442+
443+
/// The sentence Only Meetings earns, kept beside the mode it explains. Static so a test can
444+
/// read the wording without standing up an `AppState`.
445+
static let meetingsHint =
446+
"Only Meetings — the microphone stays closed until Omi detects a call, then records until it ends."
447+
369448
private var listeningTooltip: String {
370449
ShellStatusTooltip.audio(
371450
state: listeningState,
372451
mode: CaptureListeningLogic.listeningModeTitle(
373452
appState: appState, raw: audioRecordingModeRaw),
374-
isAwaitingMeeting: appState.isAwaitingMeeting)
453+
isAwaitingMeeting: appState.isAwaitingMeeting,
454+
next: CaptureListeningLogic.audioRecordingModeTitle(
455+
CaptureListeningLogic.nextAudioRecordingMode(after: listeningMode)),
456+
hasMicrophonePermission: appState.hasMicrophonePermission)
375457
}
376458

377459
private var captureState: HomeStatusState {
@@ -382,11 +464,22 @@ struct ShellStatusIcons: View {
382464

383465
// MARK: Actions — the shared logic, never a second copy
384466

385-
private func toggleListening() {
386-
CaptureListeningLogic.toggleListening(
467+
private func cycleListening() {
468+
let landed = CaptureListeningLogic.cycleListening(
387469
appState: appState,
388470
audioRecordingModeRaw: $audioRecordingModeRaw,
389471
isTogglingListening: $isTogglingListening)
472+
473+
// A click spent on the permission prompt moved nothing, so it explains nothing.
474+
meetingsHintDismissal?.cancel()
475+
guard landed == .onlyMeetings else {
476+
showsMeetingsHint = false
477+
return
478+
}
479+
showsMeetingsHint = true
480+
let dismissal = DispatchWorkItem { showsMeetingsHint = false }
481+
meetingsHintDismissal = dismissal
482+
DispatchQueue.main.asyncAfter(deadline: .now() + 4, execute: dismissal)
390483
}
391484

392485
private func toggleCapture() {

0 commit comments

Comments
 (0)