Files
Jarvis/App/MenuBar/MenuBarContextMenu.swift
james.maesandClaude Opus 4.7 67748f5336 feat(round-3): Status… menu item + NSPanel — live re-probe on open
New AppKit panel (NSPanel + SwiftUI hosting view) renders the live
BootHealthSnapshot, re-probes on every appearance, and offers
"Re-probe all" + "Copy report" buttons. Operator/dev tool, not a
user-facing surface — AppKit deliberately so it doesn't ride the
R3F render pipeline.

Anti-fake-status carries forward from Round 2: the panel's `.task`
modifier fires a fresh `runBootHealth()` sweep on every open, never
showing a cached boot snapshot. Cmd+R re-probes; Cmd+Shift+C copies.

Non-modal: NSPanel with [.titled, .closable, .resizable,
.nonactivatingPanel] + makeKeyAndOrderFront(_:) — no runModal calls.
scripts/check-no-modal-presentation.sh stays green.
isReleasedWhenClosed=false so the user can close and re-open without
re-creating the model (preserves "Copied X B" caption + last
snapshot across opens, in addition to surviving the AppKit
"window deallocs on close" default that would otherwise crash on
second open).

Files:
  - App/MenuBar/StatusPanel.swift (new):
    * StatusPanelModel — @MainActor ObservableObject, owns the
      snapshot + isProbing + copyConfirmation @Published state.
      `probeRunner` closure injected so "Re-probe all" hits the
      production AppDelegate.runBootHealth() path (single source
      of truth — panel + boot use the same code).
    * StatusPanelView — SwiftUI body. Scrollable rows, header
      with spinner during probe, footer with re-probe + copy
      buttons + caption.
    * SubsystemRow (private) — glyph (✓/⚠/✗/?), name, status
      label, latency, evidence, reason. Colors per status
      (green/orange/red/gray). Multi-line evidence + reason.
    * StatusPanel: NSPanel — hosts the SwiftUI view, `present()`
      method centers + brings front + activates app.
  - App/MenuBar/MenuBarContextMenu.swift:
    * `build` now takes a `statusAction:` closure (5th param).
    * "Status…" NSMenuItem between "Settings…" and "Show Dev Overlay".
  - App/AppDelegate.swift:
    * statusPanel: StatusPanel? lazy property.
    * openStatusPanel() method — lazy-creates panel on first click,
      passes self.runBootHealth as the probe runner closure.
    * installMenuBar's MenuBarContextMenu.build call updated for
      the new param.
  - Jarvis.xcodeproj/project.pbxproj: PBXBuildFile + PBXFileReference
    for StatusPanel.swift, added to MenuBar PBXGroup + Sources phase.

Verification:
  - bash scripts/check-app-builds.sh: PASS
  - bash scripts/check-no-modal-presentation.sh: OK
  - bash scripts/check-install-order.sh: PASS
  - bash scripts/check-no-leftover-stubs.sh: PASS

Dogfooding next: cold-launch → right-click menu bar icon → Status…,
verify rows render with concrete evidence (mic device name, model
ID list, etc.); click Re-probe and confirm rows refresh with new
latency values; click Copy report and paste into a text editor to
verify JSON is well-formed and complete.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:12:37 -05:00

84 lines
3.1 KiB
Swift

import AppKit
import ObjectiveC.runtime
/// Builds the menu-bar right-click / Ctrl-click menu per UI-SPEC Surface 4 lines 428-436.
@MainActor
public enum MenuBarContextMenu {
public static func build(
setupAction: @escaping () -> Void,
settingsAction: @escaping () -> Void,
devOverlayToggleAction: @escaping () -> Void,
stateDumpAction: @escaping () -> Void,
statusAction: @escaping () -> Void
) -> NSMenu {
let menu = NSMenu()
menu.addItem(makeItem(title: "Setup…", action: setupAction))
// Settings… opens the freeform settings panel
// (`SettingsWindowController`). The user can change the API key,
// re-grant Input Monitoring, or rebind the global hotkey from there.
// Distinct from Setup… (the sequential first-run wizard); both
// surfaces share the same underlying `WizardState`.
menu.addItem(makeItem(title: "Settings…", action: settingsAction))
menu.addItem(NSMenuItem.separator())
// Round 3 — operator-facing live subsystem health. Opens an
// AppKit panel that re-probes on every appearance.
menu.addItem(makeItem(title: "Status…", action: statusAction))
menu.addItem(makeItem(title: "Show Dev Overlay", action: devOverlayToggleAction))
menu.addItem(makeItem(title: "Copy State Dump", action: stateDumpAction))
menu.addItem(NSMenuItem.separator())
let quit = NSMenuItem(
title: "Quit Jarvis",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q"
)
quit.keyEquivalentModifierMask = [.command]
menu.addItem(quit)
return menu
}
/// Build an NSMenuItem with a closure action.
///
/// `NSMenuItem.target` is a `weak` property, so the obvious
/// `item.target = ClosureTarget(...)` pattern lets the trampoline
/// deallocate immediately and the menu item silently does nothing
/// when clicked. We keep the trampoline alive for the menu item's
/// lifetime via objc_setAssociatedObject on the item itself.
private static func makeItem(title: String, action: @escaping () -> Void) -> NSMenuItem {
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
let target = ClosureTarget(action: action)
item.target = target
item.action = #selector(ClosureTarget.run)
objc_setAssociatedObject(
item,
&MenuBarContextMenu.trampolineKey,
target,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
return item
}
/// Address used as the associated-object key. The address itself is the
/// key — the value of `trampolineKey` is irrelevant.
private static var trampolineKey: UInt8 = 0
}
/// Objective-C-visible trampoline so `NSMenuItem` can call closure-based actions.
/// Strong-ref is established by `objc_setAssociatedObject` in `makeItem`.
@MainActor
private final class ClosureTarget: NSObject {
let action: () -> Void
init(action: @escaping () -> Void) {
self.action = action
super.init()
}
@objc func run() {
action()
}
}