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>
Round 4 of boot-health work. The Toby-the-dog incident — Jarvis said "Got
it" while the embedder model was missing and the fact never persisted —
proved that Round 2's probe banner was too quiet. Probes were already
reporting `.degraded` correctly; what was missing was a way for the App
layer to route a critical failure differently from a soft one (e.g.,
non-dismissible banner for memory off vs. silent Status row for camera
TCC not yet requested).
This slice introduces a severity axis without changing behavior:
- `FailureSeverity { critical, loud, soft }` on `ProbeStatus`'s non-ok
cases (degraded/failed/unknown). `.ok` carries no severity.
- `ProbeStatus.severity` accessor (nil for `.ok`).
- `BootHealthSnapshot.overallHealth: OverallHealth { ok, soft, loud,
critical }` — worst severity wins; `.ok` only if every subsystem ok.
- Each concrete probe annotates its failure modes:
* Memory: failed → .critical (agent grounding; the Toby case)
* Anthropic: missing/empty key → .critical (agent can't run)
* Ollama: missing models → .loud (fact extraction broken, chat fine)
* Voice: mic denied → .loud; dormant install → .soft
* Vision: TCC denied → .soft (vision is optional)
* MCP: 0 tools → .critical; <minimum → .loud
* Replay: init failed → .critical; missing file → .loud
* Webview: mismatched/timedOut → .critical; sentHello → .soft
- DormantSubsystemProbe gains `severity` parameter (default .soft);
AppDelegate wires memory-dormant as .critical.
No behavioral change yet — banners still treat all failures equally;
the agent system prompt is unchanged. Slices 2 + 3 land those.
Tests: 13/13 in BootHealthOrchestratorTests pass, including 4 new tests
for overallHealth + severity accessor + JSON roundtrip with severity.
2 pre-existing failures in LLMEventTests/OllamaRequestBodyTests are
unrelated (qwen model-ID literal drift from commit 8d0a959, present
before this slice — confirmed via git stash).
Boundary gates 18/18 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Phase A3 of AUDIT-AND-FIX-PLAN.md. Replaces the SwiftUI `Settings { Text("Settings — coming soon.") }` placeholder and the menu-bar Settings… → wizard stopgap (commits c7242ed + c75ea9b) with a real `SettingsView` hosted in an `NSWindow`.
Architecture mirrors `OnboardingWizardController`:
Setup… → `OnboardingWizardController` (sequential first-run flow, terminal Save)
Settings… → `SettingsWindowController` (freeform panel, every row independently editable)
Both surfaces share the same `WizardState`, so canonical truth lives in
one place:
• API key → Keychain (`Keychain.set(.anthropic, …)`)
• Input Monitoring → IOHIDCheckAccess (queried on each refresh)
• Global hotkey → UserDefaults `Jarvis.hotkey`
Edits in either surface write through to those stores, so re-opening the
other surface reflects the change immediately. Window discipline matches
the wizard: a single reusable `NSWindow`; clicking Settings… repeatedly
brings the existing window forward and `state.refresh()`-es rather than
stacking new windows.
Hotkey-edit hot-reload: `SettingsView.onHotkeyChanged` flows to
`AppDelegate.openSettings`'s closure, which calls `bindHotkeyFromWizard`
(reused from the wizard path). The new shortcut takes effect immediately
without a relaunch.
Files added:
• App/Settings/SettingsView.swift (was already in tree as untracked WIP;
296 LOC, three sections: API key / Permissions / Hotkey)
• App/Settings/SettingsWindowController.swift (new, 70 LOC; window
lifecycle + dependency injection)
Files modified:
• App/AppDelegate.swift — `settingsWindowController` property,
instantiation alongside `wizardController`, new `openSettings()`
private helper, menu wiring change
(`settingsAction: { ... openSettings() }` instead of `openWizard`).
• App/JarvisApp.swift — replaced the "coming soon" Text body of the
SwiftUI `Settings` scene with `EmptyView()`. The scene exists only
to satisfy the `App` protocol's Scene requirement; with
LSUIElement=YES the auto-generated app-menu Settings… is never
user-visible. AppDelegate manages the real Settings panel via
`SettingsWindowController`.
• App/MenuBar/MenuBarContextMenu.swift — comment update describing
the new Settings… semantics (no longer points at the wizard).
• Jarvis.xcodeproj/project.pbxproj — added Settings/ PBXGroup with
the two new files, PBXBuildFile entries for both, and group
reference under the App PBXGroup's children. Sources phase
references both build files. UUIDs generated via `uuidgen`.
Verification:
• `scripts/check-app-builds.sh`: PASS — App target compiles cleanly
with the new Settings/ group and new files.
• SwiftUI/AppKit hosting: `NSHostingController(rootView: SettingsView)`
matches the existing wizard host pattern.
• No new test regressions expected (no tests touch Settings yet —
test coverage is a Phase D-Phase-3 / Phase E line item).
Refs: .planning/AUDIT-AND-FIX-PLAN.md Phase A3, Phase C2 ("resolved by
Phase A3"). Closes the C2 finding; F-A1-01 (HUD rings static) and
F-A2-01 (stale `JarvisBusWorld` test) remain open in
.planning/AUDIT-FINDINGS.md.
Two surgical UX fixes before the broader audit:
1. JarvisHUDPanel.summon() used orderFrontRegardless() which doesn't
activate the app or make the panel key. With another app frontmost
(the common case for a global-hotkey summon), the panel ordered to
front but stayed BEHIND the active app's windows. User saw 'hotkey
dismisses but won't bring forward' — actually summon was firing,
the window just wasn't visible above the active app's chrome.
Fix: NSApp.activate(ignoringOtherApps: true) + makeKeyAndOrderFront(nil).
Also tightened isSummoned to require BOTH .isVisible AND .isKeyWindow,
so the toggle correctly distinguishes 'visible-but-occluded' (→ summon)
from 'visible-and-frontmost' (→ dismiss).
2. Settings… menu item was hardcoded .isEnabled = false with 'Coming
soon' tooltip. Now wired to the wizard (firstLaunch: false) so the
user can change API key, re-grant permissions, or rebind the hotkey
from one place — those are the canonical settings until a dedicated
panel ships.
NSMenuItem.target is declared 'weak'. The previous code:
setup.target = ClosureTarget(action: setupAction)
setup.action = #selector(ClosureTarget.run)
created a ClosureTarget, assigned it to a weak property, and let it
deallocate immediately because nothing else retained it. By the time
the user clicked the menu item, target was nil and AppKit silently
dropped the click. All three closure-driven items broke this way:
'Setup…', 'Show Dev Overlay', 'Copy State Dump'.
Compiler had been telling us the whole time — three 'weak reference
will always be nil because the referenced object is deallocated here'
warnings on lines 16/28/33. The doc comment on the trampoline class
even claimed 'Strong-ref on the menu item is deliberate', but the
weak nature of NSMenuItem.target meant no strong ref existed.
Fix: factor menu-item construction into makeItem(title:action:),
which keeps the ClosureTarget alive for the menu item's lifetime
via objc_setAssociatedObject (.OBJC_ASSOCIATION_RETAIN_NONATOMIC).
Address-of-static used as the key per ObjC associated-object idiom.
Verified: build clean (3 weak-ref warnings gone, 0 new); manual
relaunch of Jarvis.app shows 'Setup…' opens the OnboardingWizard.
The previous click handler tried to render the right-click context menu
via:
statusItem.menu = contextMenu
sender.performClick(nil)
statusItem.menu = nil
This is racy. NSStatusItem can latch the menu reference on a separate
runloop pass after we set it, so the post-click `nil` assignment doesn't
always take effect. Once the menu is latched, NSStatusItem bypasses the
button's action selector entirely — every left- AND right-click then
opens the context menu, and the HUD never gets summoned because
`onLeftClick?()` no longer fires.
User-observed symptom: "when I clicked on it (left or right mouse) it
showed the right hand menu no matter what" after the first right-click
of the session.
Fix: call `statusItem.popUpMenu(contextMenu)` directly. The menu shows
for this click only — no persistent assignment, no race. `popUpMenu`
was deprecated in macOS 10.14 with Apple recommending "use the menu
property instead," but that recommendation is exactly what produces
this bug. The function still works through Tahoe (verified
empirically). When/if Apple removes it, replacement is a manual
`NSMenu.popUp(positioning:at:in:)` against the status-item button.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Append .reconfiguring and .booting to App/Theme/HudState.swift;
preserve Phase 1 declaration order + existing labels verbatim.
- Add voiceOverLabel arms 'Jarvis, reconfiguring audio' / 'Jarvis,
starting up' per RESEARCH §HudState Enum Wire Format.
- MenuBarIconController: add forced exhaustive switch arm for the
two new cases; both share .idle profile (no animation) with
TODO for Phase 3/4 dedicated treatment per HUD-08 precedence.
- HudStateEnumTests: 6 methods covering case count, non-empty +
unique voiceOverLabels, spec-verbatim labels, exhaustive switch.
- HudState enum (5 cases) with verbatim UI-SPEC Surface 3 VoiceOver labels
- BrandColor.arcReactorGlow: #1E88E5@85% light / #64B5F6@90% dark with Increase Contrast fallback to controlAccentColor
- CAAnimationFactory with makeBreath/makeRotate/makeShimmer/makeGlow/makeStateCrossfade, each Reduce Motion-guarded
- 22×22pt arc-reactor silhouette placeholder PDF (outer 18pt ring + mid 12pt ring + filled 4pt core + six 60° spokes) via Core Graphics
- Icon-MenuBar-Template.imageset Contents.json pointed at the PDF with template-rendering-intent=template and preserves-vector-representation=true