Files
Jarvis/App/MenuBar/MenuBarIconController.swift
james.maesandClaude Opus 4.7 a9531e65e3 feat(round-4): menu-bar icon turns red when subsystems are degraded
Slice 4 of Round 4 — the visible polish. With Slices 1–3 the agent
preamble + non-dismissible banner cover the "agent must know +
user must see" loop, but a closed HUD with no banner visible can
still hide the warning. This slice paints the menu-bar icon red so
the warning is glanceable from anywhere on the screen.

Option A from the slice plan (single-writer-safe):

- `MenuBarIconController.applyHealth(_ health: OverallHealth)` paints
  `button.contentTintColor = NSColor.systemRed` for `.loud` and
  `.critical`, clears the tint for `.ok` and `.soft`. Template images
  recolor automatically with `contentTintColor` so no second icon
  asset is needed.
- `currentHealth: OverallHealth?` exposed read-only for tests.
- `AppDelegate.runBootHealth` calls `menuBarController?.applyHealth(
  snapshot.overallHealth)` after computing the degradation summary,
  so the icon updates on every probe sweep (cold boot + Status panel
  re-probe).

Single-writer invariant for HudState (HUD-08) is intact:
`contentTintColor` lives on `NSStatusItemButton`, not in HudState.
`scripts/check-single-writer-hudstate.sh` is green.

1 new test in MenuBarIconControllerTests:
  * test_applyHealthTintsRedForLoudAndCritical — exercises all four
    OverallHealth cases and asserts contentTintColor toggles correctly.

Boundary gates 18/18 green; App target compiles cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:59:48 -05:00

155 lines
6.6 KiB
Swift

import AppKit
import QuartzCore
import AgentCore
/// Owns the `NSStatusItem` and drives Core Animation on its button layer for the
/// five `HudState` transitions. Follows RESEARCH.md §Question 7 lines 753-869.
///
/// Critical gotchas cited from RESEARCH.md:
/// - Set `button.image` and animate `transform`/`opacity` via `CABasicAnimation`;
/// NEVER swap `layer.contents` (Q7 rule).
/// - After changing `layer.anchorPoint`, re-anchor the frame to the button's
/// bounds — otherwise rotation around the center jumps to bottom-left
/// (Q7 gotcha #2, Apple DevForums thread 88341).
/// - `isTemplate = true` set in code is defense-in-depth against asset-catalog
/// misconfiguration (D-13).
@MainActor
public final class MenuBarIconController {
public let statusItem: NSStatusItem
/// The context menu. Exposed so voice subsystem can add the mute-wake-word toggle (Plan 06-05).
public private(set) var contextMenu: NSMenu
private var currentState: HudState = .idle
private var onLeftClick: (() -> Void)?
private var lastAccessibilityAnnouncement: Date = .distantPast
public init(statusItem: NSStatusItem, contextMenu: NSMenu) {
self.statusItem = statusItem
self.contextMenu = contextMenu
configureButton()
}
/// Wired by `AppDelegate` — opens/dismisses the HUD panel.
public func setLeftClickAction(_ action: @escaping () -> Void) {
self.onLeftClick = action
}
/// Current state, exposed read-only for tests.
public var state: HudState { currentState }
/// Round 4 — current overall boot-health, exposed read-only for tests.
/// `nil` until `applyHealth(_:)` is first called.
public private(set) var currentHealth: OverallHealth?
/// Round 4 — apply a boot-health tint to the menu-bar icon. `.loud` and
/// `.critical` paint a red `contentTintColor` so the icon visibly
/// communicates that something is wrong even when no HUD is open.
/// `.ok` and `.soft` clear the tint (system label color resumes).
///
/// Why `contentTintColor` and not a separate image: template images
/// already swap colors with `contentTintColor` (Apple's documented
/// behavior), so we get the red without bundling a second asset.
/// Critical: we don't write to HudState — the single-writer invariant
/// for HudState (HUD-08) stays intact.
public func applyHealth(_ health: OverallHealth) {
currentHealth = health
guard let button = statusItem.button else { return }
switch health {
case .loud, .critical:
button.contentTintColor = NSColor.systemRed
case .ok, .soft:
button.contentTintColor = nil
}
}
/// Transition to a new HUD state. Same-state transitions are no-ops.
public func transition(to newState: HudState) {
guard newState != currentState else { return }
guard let button = statusItem.button, let layer = button.layer else { return }
layer.add(CAAnimationFactory.makeStateCrossfade(), forKey: "crossfade")
layer.removeAnimation(forKey: "state.breath")
layer.removeAnimation(forKey: "state.rotate")
layer.removeAnimation(forKey: "state.shimmer")
layer.removeAnimation(forKey: "state.glow")
switch newState {
case .idle:
break
case .listening:
if let a = CAAnimationFactory.makeBreath() { layer.add(a, forKey: "state.breath") }
case .thinking:
if let a = CAAnimationFactory.makeRotate() { layer.add(a, forKey: "state.rotate") }
case .speaking:
if let a = CAAnimationFactory.makeShimmer() { layer.add(a, forKey: "state.shimmer") }
case .awaitingConfirmation:
if let a = CAAnimationFactory.makeGlow() { layer.add(a, forKey: "state.glow") }
case .reconfiguring, .booting:
// TODO(Phase 3/4): dedicated animations; these two share .idle's
// profile per HUD-08 "reconfiguring literally lowest" precedence
// (RESEARCH Open Q #4). No animation added.
break
}
currentState = newState
button.setAccessibilityLabel(newState.voiceOverLabel)
announceRateLimited(newState.voiceOverLabel)
}
// MARK: - Private
private func configureButton() {
guard let button = statusItem.button else { return }
button.image = NSImage(named: "Icon-MenuBar-Template")
button.image?.isTemplate = true
button.wantsLayer = true
if let layer = button.layer {
layer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
// Re-anchor after changing anchorPoint (Q7 gotcha #2 — rotation around
// the center jumps to bottom-left without this).
layer.frame = button.bounds
}
button.setAccessibilityLabel(currentState.voiceOverLabel)
button.setAccessibilityHelp("Click to summon or dismiss Jarvis. Right-click for more options.")
button.setAccessibilityRole(.button)
// `sendAction:to:` lets us inspect NSApp.currentEvent for left vs right click.
button.target = self
button.action = #selector(handleClick(_:))
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
}
@objc private func handleClick(_ sender: NSStatusBarButton) {
guard let event = NSApp.currentEvent else { return }
let isRightClick = event.type == .rightMouseUp
|| event.modifierFlags.contains(.control)
if isRightClick {
// Pop up the context menu directly via the status item without
// assigning it as `statusItem.menu`. The previous "temporarily
// assign + performClick + clear" pattern was racy: NSStatusItem
// can latch the menu on a later runloop pass, after which left-
// and right-clicks both surface the menu and the left-click
// action never fires. `popUpMenu(_:)` is documented (deprecated
// in 10.14, but still functional through Tahoe) and only
// displays the menu for the current click — no persistent state.
statusItem.popUpMenu(contextMenu)
} else {
onLeftClick?()
}
}
private func announceRateLimited(_ label: String) {
// UI-SPEC line 421: VoiceOver announcements limited to once per 3s to
// avoid thinking→speaking→thinking churn spamming the user.
let now = Date()
guard now.timeIntervalSince(lastAccessibilityAnnouncement) >= 3.0 else { return }
lastAccessibilityAnnouncement = now
NSAccessibility.post(
element: NSApp as Any,
notification: .announcementRequested,
userInfo: [.announcement: label]
)
}
}