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>
This commit is contained in:
1 parent
687b80b3f0
commit
67748f5336
4 files changed
+304
-2
No files matched your search
+24
-1
@@ -183,6 +183,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
/// are user-driven from the Status menu.
|
||||
private var bootHealthTask: Task<Void, Never>?
|
||||
|
||||
/// Round 3 — lazy `StatusPanel`. Constructed on first menu click;
|
||||
/// kept alive across closes via `isReleasedWhenClosed = false`.
|
||||
private var statusPanel: StatusPanel?
|
||||
|
||||
/// Drain task spawned in `applicationWillFinishLaunching` — reads
|
||||
/// envelopes from `orchToReplayChannel` and forwards to `replayLog`.
|
||||
/// CR-02: production consumer for ME-04. Previously this task
|
||||
@@ -748,6 +752,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
enqueueFailedBootHealthBanners(snapshot: snapshot)
|
||||
}
|
||||
|
||||
/// Round 3 — opens the Status panel (lazy-creates on first click).
|
||||
/// The panel's `.task { await model.reprobe() }` SwiftUI modifier
|
||||
/// fires a fresh probe sweep on every appearance, so the user always
|
||||
/// sees live state — never a stale boot snapshot.
|
||||
@MainActor
|
||||
func openStatusPanel() {
|
||||
if statusPanel == nil {
|
||||
let model = StatusPanelModel(
|
||||
orchestrator: bootHealthOrchestrator,
|
||||
probeRunner: { [weak self] in
|
||||
await self?.runBootHealth()
|
||||
}
|
||||
)
|
||||
statusPanel = StatusPanel(model: model)
|
||||
}
|
||||
statusPanel?.present()
|
||||
}
|
||||
|
||||
/// Constructs one probe per subsystem and registers it on the
|
||||
/// orchestrator. Each probe captures a strong reference to the live
|
||||
/// actor it queries — at probe-time the actor must be addressable.
|
||||
@@ -2298,7 +2320,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
setupAction: { [weak self] in self?.openWizard(firstLaunch: false) },
|
||||
settingsAction: { [weak self] in self?.openSettings() },
|
||||
devOverlayToggleAction: { [weak self] in self?.toggleDevOverlay() },
|
||||
stateDumpAction: { [weak self] in self?.copyStateDump() }
|
||||
stateDumpAction: { [weak self] in self?.copyStateDump() },
|
||||
statusAction: { [weak self] in self?.openStatusPanel() }
|
||||
)
|
||||
let controller = MenuBarIconController(statusItem: item, contextMenu: menu)
|
||||
controller.setLeftClickAction { [weak self] in self?.toggleHUD() }
|
||||
|
||||
@@ -8,7 +8,8 @@ public enum MenuBarContextMenu {
|
||||
setupAction: @escaping () -> Void,
|
||||
settingsAction: @escaping () -> Void,
|
||||
devOverlayToggleAction: @escaping () -> Void,
|
||||
stateDumpAction: @escaping () -> Void
|
||||
stateDumpAction: @escaping () -> Void,
|
||||
statusAction: @escaping () -> Void
|
||||
) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
|
||||
@@ -22,6 +23,9 @@ public enum MenuBarContextMenu {
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import AgentCore
|
||||
|
||||
// Round 3 — Status menu dialog per the 2026-05-11 handoff.
|
||||
//
|
||||
// `NSPanel` + SwiftUI body that renders the current `BootHealthSnapshot`,
|
||||
// re-probes on every open ("NOT FAKED 0 100% real only" — never show a
|
||||
// stale cached snapshot), and exposes "Re-probe all" + "Copy report"
|
||||
// buttons. AppKit, not WKWebView: this is an operator/dev tool, not a
|
||||
// user-facing surface, and shouldn't ride the R3F render pipeline.
|
||||
//
|
||||
// Non-modal — uses `makeKeyAndOrderFront(_:)`, not `.runModal()`. Project
|
||||
// rule `scripts/check-no-modal-presentation.sh` is the gate that keeps it
|
||||
// that way.
|
||||
|
||||
// MARK: - StatusPanelModel
|
||||
|
||||
/// View-model wrapping the `BootHealthOrchestrator`. Exposes a published
|
||||
/// `snapshot` for SwiftUI, an `isProbing` flag for the spinner, and a
|
||||
/// `reprobe()` async method that AppDelegate's `runBootHealth()` drives
|
||||
/// (so the snapshot read in the panel matches the snapshot logged to
|
||||
/// the system log — single source of truth).
|
||||
@MainActor
|
||||
public final class StatusPanelModel: ObservableObject {
|
||||
@Published public var snapshot: BootHealthSnapshot?
|
||||
@Published public var isProbing: Bool = false
|
||||
@Published public var copyConfirmation: String?
|
||||
|
||||
private let orchestrator: BootHealthOrchestrator
|
||||
/// Closure that runs the same registration + logging + banner-enqueue
|
||||
/// sweep AppDelegate ran at boot. Injected so the panel's "Re-probe"
|
||||
/// button hits the production code path, not a parallel one.
|
||||
private let probeRunner: @MainActor () async -> Void
|
||||
|
||||
public init(
|
||||
orchestrator: BootHealthOrchestrator,
|
||||
probeRunner: @escaping @MainActor () async -> Void
|
||||
) {
|
||||
self.orchestrator = orchestrator
|
||||
self.probeRunner = probeRunner
|
||||
}
|
||||
|
||||
/// Run a fresh sweep, then read the new snapshot off the orchestrator.
|
||||
public func reprobe() async {
|
||||
isProbing = true
|
||||
copyConfirmation = nil
|
||||
await probeRunner()
|
||||
snapshot = await orchestrator.lastSnapshot()
|
||||
isProbing = false
|
||||
}
|
||||
|
||||
/// Encode the snapshot as pretty-printed JSON and write to the
|
||||
/// general pasteboard. Updates `copyConfirmation` for the caption
|
||||
/// next to the button — auto-clears on next re-probe.
|
||||
public func copyReport() {
|
||||
guard let snap = snapshot else { return }
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(snap),
|
||||
let s = String(data: data, encoding: .utf8) else {
|
||||
copyConfirmation = "Encode failed"
|
||||
return
|
||||
}
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(s, forType: .string)
|
||||
copyConfirmation = "Copied \(data.count) B"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI view
|
||||
|
||||
struct StatusPanelView: View {
|
||||
@ObservedObject var model: StatusPanelModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Jarvis Status").font(.title2).bold()
|
||||
Spacer()
|
||||
if model.isProbing {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let snap = model.snapshot {
|
||||
ForEach(snap.subsystems, id: \.name) { health in
|
||||
SubsystemRow(health: health)
|
||||
}
|
||||
Text("Probed \(snap.subsystems.count) subsystems at \(snap.producedAt.formatted(date: .omitted, time: .standard))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 6)
|
||||
} else if model.isProbing {
|
||||
Text("Probing…")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Probes haven't run yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Re-probe all") {
|
||||
Task { await model.reprobe() }
|
||||
}
|
||||
.disabled(model.isProbing)
|
||||
.keyboardShortcut("r", modifiers: [.command])
|
||||
|
||||
Spacer()
|
||||
|
||||
if let conf = model.copyConfirmation {
|
||||
Text(conf)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
Button("Copy report") {
|
||||
model.copyReport()
|
||||
}
|
||||
.disabled(model.snapshot == nil)
|
||||
.keyboardShortcut("c", modifiers: [.command, .shift])
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 480, minHeight: 380)
|
||||
.task {
|
||||
// Always re-probe on open. The handoff's mandate is live
|
||||
// state, never a cached boot snapshot.
|
||||
await model.reprobe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
private struct SubsystemRow: View {
|
||||
let health: SubsystemHealth
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Text(glyph)
|
||||
.font(.body.bold())
|
||||
.foregroundColor(color)
|
||||
.frame(width: 18, alignment: .center)
|
||||
.accessibilityLabel(statusLabel)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(health.name)
|
||||
.font(.body.weight(.medium))
|
||||
Text(statusLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(health.latencyMs) ms")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(health.evidence)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let reason = reasonText {
|
||||
Text(reason)
|
||||
.font(.caption)
|
||||
.foregroundColor(reasonColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var glyph: String {
|
||||
switch health.status {
|
||||
case .ok: return "✓"
|
||||
case .degraded: return "⚠"
|
||||
case .failed: return "✗"
|
||||
case .unknown: return "?"
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch health.status {
|
||||
case .ok: return .green
|
||||
case .degraded: return .orange
|
||||
case .failed: return .red
|
||||
case .unknown: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private var statusLabel: String {
|
||||
switch health.status {
|
||||
case .ok: return "ok"
|
||||
case .degraded: return "degraded"
|
||||
case .failed: return "failed"
|
||||
case .unknown: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// The reason string is only meaningful for non-ok statuses; for `.ok`
|
||||
/// the evidence line is already the concrete proof of health.
|
||||
private var reasonText: String? {
|
||||
switch health.status {
|
||||
case .ok: return nil
|
||||
case .degraded(let r), .failed(let r), .unknown(let r): return r
|
||||
}
|
||||
}
|
||||
|
||||
private var reasonColor: Color {
|
||||
switch health.status {
|
||||
case .failed: return .red
|
||||
case .degraded: return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSPanel
|
||||
|
||||
/// Floating panel that owns the SwiftUI hosting view. Constructed once
|
||||
/// on first `Status…` menu click and kept alive across closes via
|
||||
/// `isReleasedWhenClosed = false` (default NSPanel behavior releases the
|
||||
/// window on close → second open crashes).
|
||||
@MainActor
|
||||
public final class StatusPanel: NSPanel {
|
||||
public let model: StatusPanelModel
|
||||
private let hosting: NSHostingView<StatusPanelView>
|
||||
|
||||
public init(model: StatusPanelModel) {
|
||||
self.model = model
|
||||
self.hosting = NSHostingView(rootView: StatusPanelView(model: model))
|
||||
super.init(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 480, height: 380),
|
||||
styleMask: [.titled, .closable, .resizable, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
title = "Jarvis Status"
|
||||
isFloatingPanel = false
|
||||
becomesKeyOnlyIfNeeded = false
|
||||
isReleasedWhenClosed = false
|
||||
hidesOnDeactivate = false
|
||||
hosting.autoresizingMask = [.width, .height]
|
||||
contentView = hosting
|
||||
}
|
||||
|
||||
/// Bring panel onto screen + give it focus. Non-modal — caller
|
||||
/// continues running. SwiftUI's `.task` modifier on the view body
|
||||
/// fires a re-probe automatically on the appearance pass.
|
||||
public func present() {
|
||||
if !isVisible {
|
||||
center()
|
||||
}
|
||||
makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
01787A3F29649644D0B3090D /* MCPAppleScriptMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5077B9E3E7541D72CE0EB1B /* MCPAppleScriptMain.swift */; };
|
||||
B0010002000000000000B002 /* BootHealthProbes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0010001000000000000B001 /* BootHealthProbes.swift */; };
|
||||
B0020002000000000000B002 /* StatusPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0020001000000000000B001 /* StatusPanel.swift */; };
|
||||
0288ABA48E6EC8450BE4BC5F /* BannerContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96BF0892A5DFF2AD38A93834 /* BannerContent.swift */; };
|
||||
07E7F32B3857ED215DE59FBE /* Keychain in Frameworks */ = {isa = PBXBuildFile; productRef = 1440CCDB2CC412B099BD0310 /* Keychain */; };
|
||||
0B1E24FF2DB306F28A36680E /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 8B5C832427B631F1F6EED6C2 /* Logging */; };
|
||||
@@ -146,6 +147,7 @@
|
||||
/* Begin PBXFileReference section */
|
||||
00C01D284ECCFC986C7F2E16 /* Keychain */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Keychain; path = packages/Keychain; sourceTree = SOURCE_ROOT; };
|
||||
B0010001000000000000B001 /* BootHealthProbes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootHealthProbes.swift; sourceTree = "<group>"; };
|
||||
B0020001000000000000B001 /* StatusPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPanel.swift; sourceTree = "<group>"; };
|
||||
024EE01ED6CC98A61A5CD96C /* WizardStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardStateTests.swift; sourceTree = "<group>"; };
|
||||
0358E0CAC1377752398FA3C4 /* ReplayingToolResultObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayingToolResultObserver.swift; sourceTree = "<group>"; };
|
||||
096AD64E7DB48B0F1EE47F89 /* HUDBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDBanner.swift; sourceTree = "<group>"; };
|
||||
@@ -370,6 +372,7 @@
|
||||
604976A6CA41878D67AF7B71 /* CAAnimationFactory.swift */,
|
||||
B5540C076C8C87A8060B8881 /* MenuBarContextMenu.swift */,
|
||||
4DFECDF801143C9F672B5FB7 /* MenuBarIconController.swift */,
|
||||
B0020001000000000000B001 /* StatusPanel.swift */,
|
||||
);
|
||||
path = MenuBar;
|
||||
sourceTree = "<group>";
|
||||
@@ -1344,6 +1347,7 @@
|
||||
8EF7109838AD91E95506BD1B /* MCPRuntimeWiring.swift in Sources */,
|
||||
A20E24D0AB73FFBBE4AB9B9B /* MenuBarContextMenu.swift in Sources */,
|
||||
9933987292A1035BB048D0B9 /* MenuBarIconController.swift in Sources */,
|
||||
B0020002000000000000B002 /* StatusPanel.swift in Sources */,
|
||||
7EFA9CFF57B04C03347D3174 /* OnboardingWizardController.swift in Sources */,
|
||||
EF9A869A3403AF044999784F /* ProductionChunkPump.swift in Sources */,
|
||||
63C78894699F60D1DE3A6715 /* RejectReasonCopy.swift in Sources */,
|
||||
|
||||
Reference in new issue
Block a user