Files
Jarvis/App/HUD/HUDBanner.swift
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

91 lines
3.8 KiB
Swift

import SwiftUI
import AppKit
/// SwiftUI body for a single HUD banner. Layout per UI-SPEC Surface 5
/// "Internal layout (horizontal)" lines 467-469. The leading 4pt accent stripe
/// uses `BrandColor.arcReactorGlow` which already handles Increase Contrast
/// fallback and light/dark variants.
struct HUDBanner: View {
let content: BannerContent
let onAction: () -> Void
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 0) {
Rectangle()
.fill(Color(nsColor: BrandColor.arcReactorGlow))
.frame(width: 4)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(content.title)
.font(.headline)
.foregroundColor(Color(NSColor.labelColor))
// `.fixedSize(horizontal: false, vertical: true)` opts
// into multi-line wrap instead of tail-truncation in
// the 360pt-wide banner. `HUDBannerPanel.positionTopTrailing`
// re-reads `contentView.fittingSize.height` on host, so
// wrapping picks up extra rows in the panel sizing.
Text(content.body)
.font(.body)
.foregroundColor(Color(NSColor.secondaryLabelColor))
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 8)
// Round 4 — hide the dismiss X for non-dismissible
// banners (critical subsystem failures). The coordinator
// also no-ops `dismissCurrent` for these, but hiding
// the affordance prevents user confusion.
if !content.nonDismissible {
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .semibold))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
.foregroundColor(Color(NSColor.secondaryLabelColor))
.accessibilityLabel("Dismiss notification")
}
}
if let action = content.action {
Button(action.label, action: onAction)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
.padding(.leading, 16)
.padding(.trailing, 16)
.padding(.vertical, 12)
}
.background(
VisualEffectBackground(material: .hudWindow, blendingMode: .behindWindow)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color(NSColor.separatorColor), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
/// Bridge `NSVisualEffectView` into SwiftUI for the `.hudWindow` material behind
/// banner content.
struct VisualEffectBackground: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
func makeNSView(context: Context) -> NSVisualEffectView {
let v = NSVisualEffectView()
v.material = material
v.blendingMode = blendingMode
v.state = .active
return v
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
nsView.material = material
nsView.blendingMode = blendingMode
}
}