Commit Graph
14 Commits
Author SHA1 Message Date
james.maes 795c3bf5c9 feat(app): v0.1 UX surfaces — startup panels + InputMonitoring banner + PTT (#87, #88, #89, #156)
#89: new AppDelegate.startupSurfacesMode enum (.minimal/.diagnostic). Debug defaults .diagnostic; Release defaults .minimal. openStartupSurfacesIfDiagnostic() runs at tail of bootHealthTask (skipped under XCTest or when wizard is open). Status panel top-right + Dev Overlay bottom-right of NSScreen.main.visibleFrame. DevOverlay opens to Logs tab.

#88: BannerContent.inputMonitoringDenied is now nonDismissible: true. New HUDBannerCoordinator.dismiss(id:) clears a banner programmatically without writing dismissedThisLaunch. AppDelegate.reprobeInputMonitoring fires on NSApplication.didBecomeActiveNotification; rebinds the global hotkey on re-grant. Uses IOHIDCheckAccess (query-only, no prompt) per CLAUDE.md guidance. 3 new coordinator tests.

#87: WizardState.pttHotkey persisted under Jarvis.pttHotkey (independent of summon hotkey). Ships unbound per CLAUDE.md hotkey hygiene. AppDelegate.rebindPTTFromState() called from installVoice, foreground TCC re-grant, Settings Save/Clear. SettingsView extended with PTT hotkey section. Audio-graph isolation: NSEvent monitors only, no audio-graph surgery — safe alongside fix/voice-crit-v0.1. 3 new WizardState tests.

DevOverlay 14 + Voice 26 tests pass; all 17 grep boundary gates green (incl. single-writer-hudstate + no-modal-presentation).
2026-05-13 06:32:16 -05:00
james.maesandClaude Opus 4.7 79955b67b7 feat(round-4): non-dismissible banners for critical subsystem failures
Slice 2 of Round 4. With Slice 1 the probe layer now reports severity;
this slice routes severity to the banner system so the user CANNOT
silently dismiss a "your memory is dead, Jarvis will lie to you" warning.

- `BannerContent.nonDismissible: Bool` (default false).
- `HUDBanner` hides the dismiss X when `content.nonDismissible == true`.
- `HUDBannerCoordinator.dismissCurrent()` no-ops on non-dismissible
  banners; the banner stays on screen until programmatically cleared
  (`clear()` on app quit, or a re-probe finding the subsystem healthy
  enqueues a different banner that pre-empts it).
- Two new presets:
  * `bootHealthCritical(subsystem:reason:)` — priority 1, non-dismissible.
    Title format: "Memory: critical failure". Body promises that the
    agent will be warned not to rely on this subsystem (Slice 3).
  * `bootHealthLoud(subsystem:reason:)` — priority 3, dismissible.
- Existing `bootHealthFailed(...)` preset kept as backwards-compat
  fallback (no current call sites after this slice).
- `AppDelegate.enqueueFailedBootHealthBanners` rewritten to iterate
  every non-ok subsystem and route by severity:
    .critical → bootHealthCritical + `[system] CRITICAL` log line
    .loud     → bootHealthLoud
    .soft     → no banner (Status panel only)
  Previously only `.failed` cases enqueued banners, which silently
  dropped `.degraded` (the Toby case) entirely.

Two new tests in HUDBannerCoordinatorTests cover the new behavior:
  * test_nonDismissibleBannerIgnoresDismiss
  * test_criticalBannerPreemptsLowerPriorityCurrent
(Note: `xcodebuild test -only-testing JarvisAppTests` is pre-existing-
broken on this checkout due to onnxruntime static-link duplication —
confirmed by `git stash` before this slice. The test code compiles
cleanly via the regular App build; SPM-target tests for the
AgentCore-side severity model are green.)

Boundary gates 18/18 green. `swift test --package-path packages/AgentCore
--filter BootHealthOrchestratorTests` → 13/13.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:36:43 -05:00
james.maesandClaude Opus 4.7 687b80b3f0 feat(round-2): boot-phase health probes — NOT FAKED, structured logs
After all install Tasks join, runs one live probe per subsystem and
emits structured `BootHealth: subsystem=X state=Y latencyMs=Z evidence="..."`
log lines. Any subsystem reporting .failed enqueues a non-modal banner
via HUDBannerCoordinator (priority 3 — sits between hotkey-bind-failed
and ollama-url-rejected). The snapshot is stored on bootHealthOrchestrator
so Round 3's Status menu can read + re-probe.

Anti-fake-status invariant is type-level: ProbeStatus is a sum type with
.ok / .degraded(reason:) / .failed(reason:) / .unknown(reason:) — every
non-ok case carries a reason string, so a snapshot can never report
state=unknown without explaining why. A probe that can't execute MUST
return .unknown — never .ok with a fake evidence line.

New types in packages/AgentCore/Sources/AgentCore/BootHealth.swift:
  - ProbeStatus, ProbeOutcome, BootHealthProbe protocol
  - SubsystemHealth (Codable for Round 3 "Copy report"), BootHealthSnapshot
  - BootHealthOrchestrator actor — parallel TaskGroup fan-out,
    registration-order-preserved snapshot, injectable clock for tests

Eight concrete probes in App/Boot/BootHealthProbes.swift:
  - MemoryBootHealthProbe — MemoryStatsStoreAdapter.getMemoryStats round-trip
  - AnthropicBootHealthProbe — Keychain structural check (no billable
    network call at boot; Round 3 panel offers explicit network re-probe)
  - OllamaBootHealthProbe — GET /api/tags, 3s timeout, asserts qwen3.6 +
    qwen2.5-coder + nomic-embed-text all present
  - VoiceBootHealthProbe — AudioGraphOwner state + mic TCC
  - VisionBootHealthProbe — camera TCC + DiscoverySession (read-only,
    does NOT prompt TCC per RESEARCH §Example 2)
  - MCPBootHealthProbe — runtime.client.registeredToolNames count
  - ReplayBootHealthProbe — replay.sqlite presence + parent-dir writability
  - WebviewBootHealthProbe — WebviewBridge.handshakeState (.armed = live)
  - DormantSubsystemProbe — placeholder for subsystems whose actor was nil
    at probe-registration; reports .unknown honestly rather than skipping

AppDelegate wiring (step 16, runs after selfKnowledgeInstallTask which
transitively awaits every other install Task):
  - bootHealthOrchestrator: BootHealthOrchestrator stored property
  - bootHealthTask: Task<Void, Never>? cancelled in applicationWillTerminate
  - runBootHealth() async: registers probes (idempotent — re-probes from
    Round 3 reuse the existing registry), runs, logs, enqueues banners
  - emitBootHealthLog / enqueueFailedBootHealthBanners helpers

Tests (packages/AgentCore/Tests/AgentCoreTests/BootHealthOrchestratorTests.swift):
  - 8/8 passing (210ms wall — 3×200ms parallel probes prove TaskGroup
    fan-out works; serial would be 600ms+)
  - Coverage: runAll invokes every probe, snapshot preserves registration
    order, parallel execution timing, lastSnapshot persistence, .failed
    propagation, .unknown not coerced to .ok, deterministic clock
    injection for latencyMs, JSON round-trip for all 4 ProbeStatus variants

Verification before commit:
  - swift test --package-path packages/AgentCore --filter
    BootHealthOrchestratorTests: 8/8 PASS (0.210s)
  - swift test --package-path packages/Memory --filter
    MemoryExtractorTests: 11/11 PASS
  - bash scripts/check-app-builds.sh: PASS
  - 18/18 boundary gates: PASS

Dogfooding next: cold-launch + grep system.log for `BootHealth:
subsystem=` lines; force a failure (e.g. kill Ollama) and verify
banner appears. Status menu (Round 3) is next session task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:03:24 -05:00
james.maes 683be70ae6 fix(hud,menu): summon brings HUD to front + Settings menu opens wizard
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.
2026-05-02 08:04:37 -05:00
james.maes dfc237db0c feat(07-06): installMemory + installVision + TS bus protocol 2.1.0 (Task 1)
- AppDelegate gains installMemory() and installVision() mirroring installVoice's
  six-step pattern. Memory installs BEFORE voice (memory ready before first
  turn); vision AFTER voice (camera lifecycle independent).
- MemoryStore + MemoryExtractionOrchestrator + MemoryExtractionCoordinator
  constructed; AppDelegateMemoryReplaySink bridges Memory.MemoryReplaySink ->
  Replay.ReplayLog.record(_:for:). Coordinator wiring deferred until
  AgentOrchestrator lands in AppDelegate (documented in SUMMARY).
- CameraCapture + PresenceMonitor instantiated; the SINGLE PresenceSignalBus
  reference (owned by PresenceMonitor) flows via AppDelegateContextBuilderAdapter
  to ContextBuilder.installPresence and via hudStateCoordinator?.attachPresence
  to the HUD coordinator. VISION-03 boundary preserved.
- VisionRouter constructed with T1=Ollama, T2=Ollama (sidecar deferred), T3=
  Anthropic (key from Keychain); the runTurn dispatcher branch is deferred
  until AgentOrchestrator wires up.
- DisablePresence menu-bar toggle wired alongside MuteWakeWord.
- BannerContent.cameraDenied + cameraRevoked presets; cameraDegradationTask
  watches CameraCapture.degradationStream and enqueues banners.
- HudStateCoordinator.attachPresence is a generic AsyncSequence subscriber
  preserving HUD-08 single-writer invariant (presence is record-only).
- ContextBuilder.installPresence(_:) is a static no-op drain (deferred wiring
  for the future per-turn system-prompt enricher).
- TS BUS_PROTOCOL_VERSION 2.0.0 -> 2.1.0; sessionHistory + TurnRow added to
  the BusOutbound discriminator union with full decode arm. Round-trip test
  bumped accordingly. scripts/check-bus-protocol-version.sh now passes.
2026-04-29 18:40:47 -05:00
james.maes 06312a7012 feat(06-05): VoiceController state machine + barge-in routing (VOICE-14)
- Add VoiceController actor: wakeWord→listening→thinking→speaking→idle FSM
- Add VoiceState.swift: ListeningSource (.wakeWord | .ptt) + VoiceState enum
- Add VoiceInterfaces.swift: VoiceOrchestratorInterface, VoiceTTSInterface,
  VoiceBannerInterface, BusOutboundEmitter, VoiceHudIntent (source of truth)
- Add Control/AudioLevelEmitter: RingBuffer RMS at 30 Hz via BusOutboundEmitter
- Add Control/PushToTalk: NSEvent.addGlobalMonitorForEvents (VOICE-13)
- Add Control/MuteWakeWord: UserDefaults persistence, WakeWordDAG.pause/resume
- Update App/HUD/HudStateIntent.swift: typealias VoiceHudIntent = Voice.VoiceHudIntent
- Add VoiceControllerTests (V1-V4) + BargeInTests (B1-B4): all 8 pass
- VOICE-14: single cancelAndSubmit call site in bargeIn() with 200ms debounce
- T-06-05-03: transcript never passed to logger
2026-04-27 13:47:09 -05:00
james.maesandClaude Opus 4.7 1c250fd771 fix(hud): layer-clear belt-and-braces + enable WebInspector in Debug
Diagnosing the white-HUD-on-summon: the WKWebView and its container
view had `wantsLayer = true` on the container only, with no explicit
layer.backgroundColor. macOS 26's WebKit may be backing the hosting
layer with an opaque-white surface even when `setDrawsBackground:` is
honored at the WebView level. Setting both layer backgrounds to
`NSColor.clear.cgColor` is the documented belt-and-braces:

  - `webView.wantsLayer = true; webView.layer?.backgroundColor = .clear`
  - `container.layer?.backgroundColor = .clear`

Also enables `webView.isInspectable = true` (macOS 13.3+) so right-click
→ "Inspect Element" works in Debug builds. This is the canonical macOS
diagnostic for "WKWebView shows wrong color" — without it, "white
square" can mean transparency-not-honored OR JS-bundle-not-mounted OR
R3F-canvas-not-painting, and we can't tell which.

The deployment target is macOS 13.0, so the `isInspectable` setter is
behind a `#available(macOS 13.3, *)` availability guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:08:02 -05:00
james.maesandClaude Opus 4.7 8ebdf76980 fix(hud): wrap text in wizard + banner instead of tail-truncation
Both the first-launch wizard's TCC stage and the HUD banner panel were
showing tail-truncated body text (Grant Permissions descriptions ending
in "...", Limited hotkey mode body cut at "The hotke..."). Two layered
SwiftUI layout bugs:

1. The body Text inside row HStacks (`VStack(title+body) + Spacer +
   button`) didn't have `.fixedSize(horizontal: false, vertical: true)`,
   so when the trailing button was wide ("Denied — Open System Settings")
   SwiftUI's HStack layout compressed Text and chose tail-truncation
   over multi-line wrap.

2. The wizard's outer frame was `560×440`, leaving only ~318h for
   stageBody after header(64) + dividers(2) + footer(56). The TCC stage
   with 4 permission rows needed ≥520h. The vertical squeeze cascaded
   into more aggressive Text compression and clipped the bottom Back row.

Fixes:
- WizardView: bump frame to 600×640.
- WizardStageTCCView: `.fixedSize(horizontal: false, vertical: true)` on
  description + each row's body. `.frame(maxWidth: .infinity, alignment:
  .leading)` on inner row VStacks so they receive the available width
  before the trailing button claims it. Spacer minLength bumped to 16
  for breathing room between body text and button.
- HUDBanner: same `.fixedSize` + `.frame(maxWidth:)` pair on the title
  +body VStack. The 360pt-wide banner needed it most because
  `HUDBannerPanel.positionTopTrailing` re-reads the SwiftUI fittingSize
  on host — without wrapping, the panel was sized to a single-line body
  and forced all subsequent body text to truncate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:08:02 -05:00
james.maesandClaude Opus 4.7 cabc27299a feat(03-05): AppDelegate wires HudStateCoordinator + bridge; lint activated; W1-W6 + B1-B3 tests
- App/HUD/HudStateBridge.swift (new): @MainActor public func busHudState(
  from: HudState) -> Bus.HudState. RawValue round-trip with assertionFailure
  on drift, .idle fallback in Release. Option (b) from the plan — avoids
  name-collision between App.HudState and Bus.HudState via module-qualified
  Bus.HudState in the helper's scope.
- App/AppDelegate.swift:
    * New stored properties: hudStateCoordinator, dormantAgentContinuation,
      dormantVoiceContinuation, dormantConfirmContinuation (all internal so
      @testable import Jarvis can assert W5 retention), webviewEntryFilename
      (internal test seam for W6).
    * installBus(): constructs HudStateCoordinator with emit closure that
      bridges App.HudState -> Bus.HudState via busHudState() and dispatches
      to bridge.send(.hudState(...)) on the MainActor. Creates three dormant
      AsyncStream producers (Phase 4/5/6 replace them) and retains their
      continuations on self so coordinator.{agent,voice,confirm}Task stay
      live. markReady() fires from onHandshakeArmed so the HUD promotes
      .booting -> .idle as soon as the webview is responsive.
    * Load target: bus-harness.html -> index.html (via
      webviewEntryFilename property so W6 can assert post-install value).
      XCTest-host short-circuit from 02-03 deviation #5 preserved, updated
      to reference the property-driven filename.
- scripts/check-single-writer-hudstate.sh: allowlist extended to
  {HudStateCoordinator.swift, HudStateBridge.swift, AppDelegate.swift} +
  {BusOutbound.swift, Protocol.swift}. Rationale comment updated: Phase 4+
  drives the coordinator via its three input AsyncStreams, not new
  .hudState(...) call sites. Already wired as a preBuildScript by Task 1.
- App/Tests/AppTests/HudStateCoordinatorBusWiringTests.swift (new):
    * W1 test_coordinatorConstructedInInstallBus — delegate.hudStateCoordinator
      non-nil after applicationWillFinishLaunching.
    * W2 test_emitClosureBridgesToBusSend — FakeJSEvaluator observes the
      closure producing a decoded BusOutbound.hudState(.thinking) after
      coordinator.markReady() + agent.yield(.thinking). Proves the exact
      closure shape AppDelegate installs routes rawValues end-to-end.
    * W3 test_markReadyCalledOnHandshakeArmed — coordinator pinned at .booting
      pre-handshake, promotes to .idle after bridge.handleHelloAck.
    * W4 test_appHudStateRawValueMatchesBusHudState — table-driven bidirectional
      rawValue parity over HudState.allCases and Bus.HudState.allCases. Catches
      drift before busHudState()'s assertion fires at runtime.
    * W5 test_dormantStreamsHeldByDelegate — all three continuations non-nil.
    * W6 test_installBusLoadsIndexNotHarness — webviewEntryFilename == "index"
      post-installBus.
- App/Tests/AppTests/WebviewBundleLoadTests.swift (new):
    * B1 test_indexHtmlInRepoWebviewDir — structural (#filePath traversal to
      repo root), catches missing committed index.html.
    * B3 test_indexHtmlReferencesRelativeAssets — content-grep for "./assets/"
      present + "src=\"/assets/" absent. Catches Vite misconfig or absolute-
      path drift.
    * B-extra test_assetsDirectoryGitignored_documentedInSmokeScript — doc-only,
      asserts the split of responsibility between XCTest (committed index.html)
      and scripts/smoke-test-hud.sh (built-app assets/*.js).

Tests are repo-structural / evaluator-observation only; xcodebuild test
launch still blocked on Xcode 26 RunningBoard error 5 per 02-03 precedent.
Verification is xcodebuild build (succeeds) + scripts/smoke-test-hud.sh
(next task). This follows the 02-03 SUMMARY deviation #5 pattern.

xcodebuild build -configuration Debug: BUILD SUCCEEDED.
bash scripts/check-single-writer-hudstate.sh: PASS (allowlist active).
packages/Bus swift test: 47/47 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 15:53:58 -05:00
james.maes 79092263bb feat(03-01): HudStateCoordinator single-writer + intents + lint
Implements HUD-08 precedence ladder as the sole Swift-side writer of
HudState. Three producer streams (agent, voice, confirmation) feed a
MainActor-isolated resolver; one emit closure is the only path out.

- HudStateIntent.swift: AgentHudIntent/VoiceHudIntent/ConfirmHudIntent
  Sendable enums. SystemHudIntent intentionally omitted — markReady()
  is a one-shot direct call, not a stream.
- HudStateCoordinator.swift: @MainActor final class. Ladder per
  HUD-08 verbatim: awaitingConfirmation > speaking > listening >
  thinking > idle > booting > reconfiguring. Ready-gate tweak on the
  .booting step (RESEARCH Open Q #4 pre-authorized) so boot-time
  silent inputs don't prematurely resolve to .idle.
- Bus-agnostic by design: takes @escaping @MainActor (HudState) -> Void
  emit closure so Plan 03-05 can wire it to WebviewBridge without this
  plan importing Bus or WebKit.
- Weak-self capture + deinit cancel on three for-await loops prevents
  the T-03-03 leak.
- HudStateCoordinatorTests.swift: 19 methods covering every precedence
  pair, booting gate, idempotent emit, cleared-confirm restore,
  weak-self no-leak, interleaved producers.
- scripts/check-single-writer-hudstate.sh: executable; exits 0 on
  current codebase; exits 1 on planted .hudState() construction
  outside the allowlist. Pre-build wiring deferred to Plan 03-05.
2026-04-23 18:55:04 -05:00
james.maes 20e8647af4 feat(02-03): wire bus into AppDelegate + expose HUD webView + bundle harness
- JarvisHUDPanel.webView: private → public. Surgical one-line change per
  RESEARCH open question #4 — keeps the dep graph App → Bus, not inverted.
- AppDelegate.installBus(): new launch-chain step 4.5 (after installHUDPanel
  / installBannerPanel). Constructs WebviewBridge around panel.webView,
  wires alertPresenter → TCCAlertService.presentHardBlock + terminate,
  wires onHandshakeArmed → onBusArmed test hook, installs
  BridgeNavigationDelegate so WKNavigationDelegate.webView(_:didFinish:)
  triggers startHandshake, and loads bus-harness.html via
  Bundle.main.url(forResource:withExtension:subdirectory:). Missing
  harness is a hard-block (terminates) in production; silently skips
  under XCTest so unit tests assert bridge construction.
- AppDelegate.toggleHUD(): gates on handshakeState == .armed. Unarmed
  path enqueues BannerContent(id: "hud-not-ready", priority: 2) rather
  than popping an empty window.
- AppDelegate.onHandshakeMismatch + onBusArmed: @MainActor injectable
  closures for test hooks. Production default calls NSApp.terminate(nil)
  after the TCCAlertService modal returns.
- App/Resources/webview/bus-harness.html: bundle-vendored harness loaded
  from Jarvis.app/Contents/Resources/webview/bus-harness.html. Plan 04
  adds the parity check that keeps this in sync with webview/bus-harness.html.
- project.yml: adds Bus package dep, adds App/Resources/webview as a
  blue-folder reference (type: folder under sources:) so the subdirectory
  survives into the bundle. Excludes App/Resources/** from the flat
  source path to avoid double-including. xcodegen regenerates clean.
- AppDelegateBusWiringTests: 6 new test cases covering installBus
  construction, toggleHUD armed/unarmed gating, onHandshakeArmed hook
  firing, mismatch terminate hook, and Bundle.main harness lookup.

Verified:
- xcodegen generate + xcodebuild -scheme Jarvis build: BUILD SUCCEEDED.
- packages/Bus SPM tests: 47 passed / 0 failed.
- bus-harness.html + Bus_Bus.bundle/Injection.js both ship in the .app.
- grep -rn '.evaluateJavaScript(' App/ packages/Bus/Sources/: 0 matches.

Note: xcodebuild test -only-testing:JarvisAppTests fails at test-host
launch with dyld 'different Team IDs' on Xcode 26 + macOS 26 Debug — this
is a pre-existing repo-level issue documented in
.planning/debug/xctest-launch-runningboard-error-5.md and tracked in
01-HUMAN-UAT.md. Out of scope for Plan 02-03; SPM-level coverage is
complete via BatcherTests + WebviewBridgeOutboundTests.
2026-04-23 18:04:39 -05:00
james.maes 9d50e1cb56 fix(01-WR-10): guard WKWebView setDrawsBackground KVC with responds(to:)
Phase 1 REVIEW.md WR-10: `JarvisHUDPanel.init` reached into WKWebView's
private ObjC API via `setValue(false, forKey: "drawsBackground")` to
force a transparent backing (UI-SPEC Surface 7 line 562). If a future
WebKit build renames the setter, `NSUndefinedKeyException` propagates
out of the panel initializer and the app crashes at launch — instead
of degrading to an opaque HUD.

App/HUD/JarvisHUDPanel.swift
- Probe `responds(to: NSSelectorFromString("setDrawsBackground:"))`
  before calling setValue. If the selector is missing, skip the KVC
  call; the HUD renders with an opaque backing (visible regression)
  rather than crashing at launch.
2026-04-23 16:28:06 -05:00
james.maes 9fc053511c fix(01-WR-05): cancellable 300ms banner drain, close race windows
Phase 1 REVIEW.md WR-05: the 300ms drain after `dismissCurrent()` was
a fire-and-forget `DispatchQueue.main.asyncAfter`. Two races fell out
of that:
  1. `clear()` during the drain window — pending drain still fires,
     banner pops up while app is quitting.
  2. Higher-priority banner enqueued during the drain — `current` is
     nil for 300ms, so `enqueue` calls `showBanner` immediately, then
     the drained banner fires afterwards and clobbers it.

App/HUD/HUDBannerCoordinator.swift
- Add `private var drainWorkItem: DispatchWorkItem?` — cancellable
  handle on the drain.
- `dismissCurrent()`: cancel any prior drain before scheduling a new
  one (belt + suspenders — prevents two drains from racing).
- `enqueue()`: when `current == nil`, cancel any pending drain before
  calling `showBanner`. Prevents the clobber race.
- `clear()`: cancel the pending drain. Prevents pop-up-at-quit.
- Drain closure re-checks `current != nil` at fire time for defense
  in depth.

App/Tests/AppTests/HUDBannerCoordinatorTests.swift
- `test_clearCancelsPendingDrain` — enqueue two, dismiss, clear,
  wait 500ms, assert currentBanner is nil.
- `test_enqueueDuringDrainCancelsDrain` — enqueue/enqueue/dismiss/
  enqueue-higher-priority, assert higher-priority wins immediately
  AND survives past the 300ms drain window.
2026-04-23 16:20:29 -05:00
james.maes 122153d544 feat(01-03): add JarvisHUDPanel, HUDBannerPanel+Coordinator, AppDelegate entitlement hard-block
- JarvisHUDPanel: borderless 720×720 NSPanel, .statusBar level, .canJoinAllSpaces/.fullScreenAuxiliary/.transient collectionBehavior, transparent WKWebView (drawsBackground=false via KVO), cancelOperation(_:) + performClose(_:) overrides for Escape/⌘W dismiss, Reduce Transparency fallback to windowBackgroundColor@95%, fresh-center on summon via NSScreen.screenWithMouse()
- HUDBannerPanel: .floating non-activating 360pt banner panel, canBecomeKey/canBecomeMain both false (never steals focus), top-trailing positioning 24pt/8pt inset
- HUDBannerCoordinator (@MainActor): priority queue with pre-emption (lower priority int = higher precedence), dismissed-this-launch dedup by id, 300ms gap to next banner per UI-SPEC Surface 5
- BannerContent with four presets: keychain-empty (1) > input-monitoring-denied (2) > hotkey-bind-failed (3) > ollama-url-rejected (4); Sendable/Equatable/Identifiable
- HUDBanner (SwiftUI): 4pt arc-reactor-glow leading stripe + title/body/action/dismiss-× layout on .hudWindow visual-effect background with separatorColor border
- AppDelegate: EntitlementGateProbe protocol + InfoPlistEntitlementGateProbe impl + injectable onEntitlementFailure (@MainActor) defaulting to presentEntitlementFailureAlert + NSApp.terminate, skipped under XCTest host; bootstrap chain installs menu-bar + HUD panel + banner panel (Plan 04 prepends logging and appends config/keychain/hotkey)
- 9 new tests in JarvisAppTests: 3 HUD panel (style flags, summon/dismiss, Escape), 4 banner coordinator (enqueue, priority pre-emption, dismiss-drain, dismissed-this-launch dedup), 2 AppDelegate (hard-block triggers / does-not-trigger with mocked probe)
2026-04-22 13:26:40 -05:00