feat(devoverlay): wire DevSnapshotEmitter end-to-end + add tabbed view

The DevOverlay panel has been silently rendering DevSnapshot.initial since
Phase 4 because three independent breaks left the data path open-circuit
(see .planning/audit-2026-05-12/devoverlay-diagnosis.md for the full trace).

Slice 2 closes all three:

1. AppDelegate constructs `DevSnapshotEmitter` in `installAgent` and
   stores it on the delegate. Provider/model is set from configStore
   so the header row shows the active provider rather than a blank.

2. The `.devOverlay` broadcaster subscriber that previously drained to
   `/dev/null` now calls `emitter.apply(event)` for every event. This
   produces real DevSnapshots that flow through the bounded output
   channel (capacity 32, .dropOldest — AGENT-10 observational seam).

3. `DevOverlayWindow` now accepts the live emitter + BootHealthOrchestrator
   + a HudState reader closure. It instantiates its own DevOverlayBridge
   and attaches the bridge to `emitter.output` so viewModel.apply is
   actually called as snapshots arrive.

Also reshapes the SwiftUI surface as a TabView with three observational
panes (Agent, Tools, Health). The Health pane is read-only — the Status…
menu remains the place to trigger re-probes. The Logs pane lands in
Slice 3 (live log stream).

ViewModel extends with `bootHealth` + `hudStateLabel` slices, each with
its own single-writer mutator and `private(set)` enforcement.

Verification:
- swift test --package-path packages/DevOverlay → 14/14 pass
- swift test --package-path packages/AgentCore --filter DevSnapshotEmitterTests → 9/9 pass
- bash scripts/check-app-builds.sh → PASS
- 18/18 boundary gates green
This commit is contained in:
james.maes committed 2026-05-12 14:11:19 -05:00
1 parent 8d94c1c398
commit 50c2783a8c
4 files changed
+533 -109

No files matched your search

+57 -10
View File
@@ -379,9 +379,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var transcriptSubscriberTask: Task<Void, Never>?
/// Drains the broadcaster's devOverlay subscription into DevSnapshotEmitter.
/// Lossy — the DevOverlay is observational.
/// Lossy — the DevOverlay is observational. Wired in `installAgent`
/// (2026-05-12 dev-overlay-end-to-end Slice 2).
private var devOverlaySubscriberTask: Task<Void, Never>?
/// Aggregates `OrchestratorEvent` into `DevSnapshot`s for the DevOverlay
/// window. Constructed in `installAgent` once the broadcaster is alive;
/// `toggleDevOverlay` hands a reference to the lazy `DevOverlayWindow`
/// so its `DevOverlayBridge` can subscribe to `emitter.output`.
private var devSnapshotEmitter: DevSnapshotEmitter?
/// Plan 09-04 — voice path bridge. The real adapter is constructed in
/// installVoice() and held strongly here so the broadcaster's voice
/// subscriber drain in installAgent can call its `emitTurnEnded` /
@@ -1800,15 +1807,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
// 5c. DevOverlay subscriber (lossy — observational; reserved for the
// DevOverlay emitter wiring in a follow-on plan). Subscribed here
// so the broadcaster's three-subscriber pattern is established;
// the drain task simply consumes events to keep the actor's
// internal mirror flushing.
// 5c. DevOverlay subscriber — forwards every event into a live
// `DevSnapshotEmitter` so the DevOverlay window renders the
// real agent state instead of `DevSnapshot.initial`. Wired
// 2026-05-12 (dev-overlay-end-to-end Slice 2). The pre-fix
// drain was a `for await _ in …` to `/dev/null`, which
// explains why the panel had been showing zeros since P4.
//
// Provider/model is set eagerly via the same `configStore`
// read SelfStateAdapter uses. Lossy under load — DevOverlay is
// observational; the emitter's output channel is .dropOldest.
let devEmitter = DevSnapshotEmitter()
self.devSnapshotEmitter = devEmitter
// Populate provider/model header best-effort. The orchestrator
// can switch providers mid-session; the emitter's setter is the
// hook to refresh, but for now we read once at install.
if let configStore = self.configStore {
let snap = await configStore.perTurn()
let providerName: String
let modelName: String
switch snap.provider {
case .anthropic:
providerName = "anthropic"
modelName = "claude-opus-4-7"
case .ollama:
providerName = "ollama"
modelName = "qwen2.5-coder:32b"
}
await devEmitter.setProvider(provider: providerName, modelId: modelName)
}
let devSub = await broadcaster.subscribe(priority: .devOverlay, capacity: 32)
devOverlaySubscriberTask = Task {
for await _ in devSub.stream {
devOverlaySubscriberTask = Task { [devEmitter] in
for await event in devSub.stream {
if Task.isCancelled { break }
await devEmitter.apply(event)
}
}
@@ -2648,12 +2680,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Menu-bar actions
/// Lazily constructed on first toggle so the panel doesn't allocate
/// resources during launch.
/// resources during launch. Dependencies (emitter, boot-health,
/// hud-state reader) are captured at construction; if the user opens
/// the overlay before `installAgent` has built the emitter, the panel
/// will still render — it just won't show agent snapshots until the
/// emitter exists, and the boot-health pane works regardless.
private var devOverlayWindow: DevOverlayWindow?
private func toggleDevOverlay() {
if devOverlayWindow == nil {
devOverlayWindow = DevOverlayWindow()
let hudCoordinator = self.hudStateCoordinator
// HudState lives in the App target; the overlay package only
// gets a string. Closure-captured weak reference so the
// overlay never extends the coordinator's lifetime.
let reader: @MainActor () -> String = { [weak hudCoordinator] in
hudCoordinator?.currentStateForTests.rawValue ?? "—"
}
devOverlayWindow = DevOverlayWindow(
emitter: devSnapshotEmitter,
bootHealthOrchestrator: bootHealthOrchestrator,
hudStateReader: reader
)
}
devOverlayWindow?.toggle()
}
@@ -1,27 +1,43 @@
// DevOverlayView.swift
//
// SwiftUI surface for the DevOverlay. Three tabs:
//
// - Agent: live TurnState, provider/model, token + cache counters, latency.
// - Tools: last 5 tool calls (name, status glyph, duration, preview).
// - Health: subsystem health rows (reuses the BootHealth shape from the
// Status panel — but read-only; the Status menu remains the place to
// trigger a re-probe).
//
// The "Logs" tab gets added in Slice 3 by `DevOverlayLogsView`. Slice 2
// ships the three observational tabs above.
//
// Rendering is read-only. Every binding reads off `viewModel`; nothing
// in this file mutates state. The single-writer invariants live on the
// view-model.
#if canImport(SwiftUI)
import SwiftUI
import AgentCore
import AgentOrchestrator
/// SwiftUI surface for the DevOverlay (OBS-01).
/// Root DevOverlay view.
///
/// Matches the layout described in `04-RESEARCH.md §9`:
/// - State + turn-id row
/// - Provider + model row
/// - Input/output token counts row
/// - Cache creation / read row with hit percentage
/// - Latency row (TTFB + total)
/// - "Last 5 tool calls" table
/// ## Overview
///
/// Rendering is read-only — the view-model is driven by a DevSnapshot channel
/// subscriber, so nothing in this file mutates state.
/// `TabView` keeps the panel small while letting each pane render its
/// data without competing for vertical space. The `Agent` tab is the
/// default; `Tools` is the second-most-used pane during development.
///
/// ## Threading
///
/// Reads only — SwiftUI handles re-render off `@Observable` property
/// changes on the main actor.
@available(macOS 14.0, *)
public struct DevOverlayView: View {
// ME-01: `@Observable` triggers re-renders via the property-wrapper
// macros; `@State` on a reference type defeats this — it captures the
// initial reference and ignores subsequent inits. The view-model is
// injected from outside (DevOverlayWindow), so a plain `let` is the
// canonical Observation-framework pattern for read-only observation.
// ME-01: `@Observable` triggers re-renders via property-wrapper macros;
// `@State` on a reference type would capture the initial reference and
// ignore subsequent inits. Plain `let` is the canonical Observation
// pattern for read-only observation of an injected model.
public let viewModel: DevOverlayViewModel
public init(viewModel: DevOverlayViewModel) {
@@ -29,77 +45,157 @@ public struct DevOverlayView: View {
}
public var body: some View {
VStack(alignment: .leading, spacing: 6) {
TabView {
AgentPane(viewModel: viewModel)
.tabItem { Label("Agent", systemImage: "brain") }
.tag(0)
ToolsPane(viewModel: viewModel)
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
.tag(1)
HealthPane(viewModel: viewModel)
.tabItem { Label("Health", systemImage: "stethoscope") }
.tag(2)
}
.padding(8)
.frame(minWidth: 520, minHeight: 480)
.background(.regularMaterial)
}
}
// MARK: - Agent pane
/// Live agent snapshot: state, turn ID, provider/model, tokens, latency.
@available(macOS 14.0, *)
private struct AgentPane: View {
let viewModel: DevOverlayViewModel
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// HudState + TurnState side-by-side. They can disagree briefly
// (HudState lags TurnState by one resolveAndEmit hop) — showing
// both makes that lag visible rather than mysterious.
HStack {
Text("State:")
Text(stateLabel(viewModel.snapshot.state)).bold()
Text("HUD:").foregroundColor(.secondary)
Text(viewModel.hudStateLabel).bold().monospaced()
Spacer()
Text("Turn:")
Text(turnIdShort(viewModel.snapshot.turnId)).monospaced()
Text("Turn state:").foregroundColor(.secondary)
Text(stateLabel(viewModel.snapshot.state)).bold().monospaced()
}
HStack {
Text("Provider:")
Text("Turn ID:").foregroundColor(.secondary)
Text(turnIdShort(viewModel.snapshot.turnId)).monospaced()
Spacer()
Text("Provider:").foregroundColor(.secondary)
Text("\(viewModel.snapshot.provider) / \(viewModel.snapshot.modelId)").monospaced()
}
HStack {
Text("Input:")
Text("\(viewModel.snapshot.inputTokens) tok").monospaced()
Text("Output:")
Text("\(viewModel.snapshot.outputTokens) tok").monospaced()
Divider()
GroupBox("Tokens") {
VStack(alignment: .leading, spacing: 4) {
row("Input", "\(viewModel.snapshot.inputTokens) tok")
row("Output", "\(viewModel.snapshot.outputTokens) tok")
row("Cache create", "\(viewModel.snapshot.cacheCreationInputTokens) tok")
row("Cache read",
"\(viewModel.snapshot.cacheReadInputTokens) tok " +
String(format: "(%.0f%% hit)", viewModel.snapshot.cacheHitPercentage))
}
}
HStack {
Text("Cache creation:")
Text("\(viewModel.snapshot.cacheCreationInputTokens) tok").monospaced()
GroupBox("Latency (current turn)") {
VStack(alignment: .leading, spacing: 4) {
row("TTFB", "\(viewModel.snapshot.ttfbMs) ms")
row("Total", "\(viewModel.snapshot.totalMs) ms")
}
}
HStack {
Text("Cache read:")
Text("\(viewModel.snapshot.cacheReadInputTokens) tok").monospaced()
Text(String(format: "(%.0f%% hit)", viewModel.snapshot.cacheHitPercentage)).monospaced()
}
HStack {
Text("Latency:")
Text("ttfb=\(viewModel.snapshot.ttfbMs)ms").monospaced()
Text("total=\(viewModel.snapshot.totalMs)ms").monospaced()
}
Spacer()
}
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func row(_ label: String, _ value: String) -> some View {
HStack {
Text(label).foregroundColor(.secondary).frame(width: 110, alignment: .leading)
Text(value).monospaced()
Spacer()
}
}
}
// MARK: - Tools pane
/// Last 5 tool calls. Dedupes on `toolUseId` upstream in the emitter, so
/// a streaming pending → running → completed sequence renders as one row
/// updating in place rather than three rows in the ring.
@available(macOS 14.0, *)
private struct ToolsPane: View {
let viewModel: DevOverlayViewModel
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Last 5 tool calls").font(.headline)
Divider()
Text("Last 5 tool calls:").bold()
if viewModel.snapshot.toolCalls.isEmpty {
Text("—").foregroundColor(.secondary).monospaced()
Text("No tool calls yet.")
.foregroundColor(.secondary)
.monospaced()
.padding(.top, 16)
} else {
ForEach(viewModel.snapshot.toolCalls) { row in
HStack {
Text(row.name).monospaced()
Text("\(row.durationMs)ms").monospaced().foregroundColor(.secondary)
Text(symbolForStatus(row.status))
Spacer()
Text(previewText(row.preview)).foregroundColor(.secondary).monospaced()
ScrollView {
VStack(alignment: .leading, spacing: 6) {
ForEach(viewModel.snapshot.toolCalls) { row in
ToolCallRowView(row: row)
Divider()
}
}
}
}
Spacer()
}
.padding(12)
.frame(minWidth: 380, maxWidth: 460, alignment: .leading)
.background(.regularMaterial)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
private func stateLabel(_ s: TurnState) -> String {
switch s {
case .idle: return "idle"
case .booting: return "booting"
case .thinking: return "thinking"
case .speaking: return "speaking"
case .listening: return "listening"
case .awaitingConfirmation(let id): return "awaiting(\(id.rawValue))"
case .reconfiguring: return "reconfiguring"
@available(macOS 14.0, *)
private struct ToolCallRowView: View {
let row: ToolCallRow
var body: some View {
HStack(alignment: .top, spacing: 8) {
Text(symbol(row.status))
.frame(width: 18, alignment: .center)
.foregroundColor(color(row.status))
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(row.name).bold().monospaced()
Spacer()
Text("\(row.durationMs) ms")
.font(.caption.monospaced())
.foregroundColor(.secondary)
}
if let preview = row.preview, !preview.isEmpty {
Text(preview)
.font(.caption.monospaced())
.foregroundColor(.secondary)
.lineLimit(2)
.truncationMode(.tail)
}
if let err = row.error, !err.isEmpty {
Text(err)
.font(.caption)
.foregroundColor(.red)
.lineLimit(2)
}
}
}
}
private func turnIdShort(_ id: TurnID?) -> String {
guard let raw = id?.rawValue, !raw.isEmpty else { return "—" }
return String(raw.prefix(8))
}
private func symbolForStatus(_ s: ToolCallRow.Status) -> String {
private func symbol(_ s: ToolCallRow.Status) -> String {
switch s {
case .pending: return "⏸"
case .running: return "…"
@@ -109,9 +205,144 @@ public struct DevOverlayView: View {
}
}
private func previewText(_ preview: String?) -> String {
guard let p = preview, !p.isEmpty else { return "—" }
return String(p.prefix(30))
private func color(_ s: ToolCallRow.Status) -> Color {
switch s {
case .pending, .running, .awaitingApproval: return .secondary
case .completed: return .green
case .failed: return .red
}
}
}
// MARK: - Health pane
/// Read-only BootHealth view. The Status menu remains the place to
/// trigger a re-probe; the DevOverlay just shows whatever the last
/// `lastSnapshot()` returned.
@available(macOS 14.0, *)
private struct HealthPane: View {
let viewModel: DevOverlayViewModel
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Subsystem health").font(.headline)
Divider()
if let snap = viewModel.bootHealth {
ScrollView {
VStack(alignment: .leading, spacing: 6) {
ForEach(snap.subsystems, id: \.name) { health in
HealthRowView(health: health)
}
Text("Probed at \(snap.producedAt.formatted(date: .omitted, time: .standard))")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 6)
}
}
} else {
Text("BootHealth has not run yet. Open Status… to trigger.")
.foregroundColor(.secondary)
.padding(.top, 16)
}
Spacer()
}
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
@available(macOS 14.0, *)
private struct HealthRowView: 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)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(health.name).font(.body.weight(.medium))
Text(statusLabel).font(.caption).foregroundColor(.secondary)
Spacer()
Text("\(health.latencyMs) ms")
.font(.caption.monospaced())
.foregroundColor(.secondary)
}
Text(health.evidence)
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let r = reasonText {
Text(r).font(.caption).foregroundColor(reasonColor)
}
}
}
.padding(.vertical, 2)
}
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"
}
}
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: - Helpers
@available(macOS 14.0, *)
private func stateLabel(_ s: TurnState) -> String {
switch s {
case .idle: return "idle"
case .booting: return "booting"
case .thinking: return "thinking"
case .speaking: return "speaking"
case .listening: return "listening"
case .awaitingConfirmation(let id): return "awaiting(\(id.rawValue))"
case .reconfiguring: return "reconfiguring"
}
}
@available(macOS 14.0, *)
private func turnIdShort(_ id: TurnID?) -> String {
guard let raw = id?.rawValue, !raw.isEmpty else { return "—" }
return String(raw.prefix(8))
}
#endif
@@ -1,41 +1,81 @@
// DevOverlayViewModel.swift
//
// `@Observable` view-model driving DevOverlayView. Holds three slices of
// state:
//
// 1. `snapshot` — DevSnapshot from the orchestrator event pipeline.
// Single-writer path: `DevOverlayBridge`'s subscriber Task calls
// `apply(_:)`. `private(set)` on the property enforces the gate by
// type, not convention (ME-02).
//
// 2. `bootHealth` — last cached BootHealthSnapshot from the orchestrator
// actor. Updated by `DevOverlayWindow`'s 1 Hz poll.
//
// 3. `hudStateLabel` — string form of the App-side HudState (the App
// target owns the enum; we accept a closure to avoid the dep).
//
// Single write paths for all three. The UI is read-only.
import Foundation
import Observation
import AgentCore
import AgentOrchestrator
/// @Observable, @MainActor view-model driving `DevOverlayView`.
/// View-model for `DevOverlayView`.
///
/// **Single write path:** the DevSnapshot channel subscriber Task (see
/// `DevOverlayBridge`). The UI reads `snapshot` but never mutates it. Keeps
/// the rendering deterministic: whatever `DevSnapshotEmitter` says is the
/// current state is the state the UI renders, with no local drift.
/// ## Overview
///
/// `@Observable` is macOS 14+ (Observation framework). The DevOverlay package
/// raises its platform floor accordingly; the main app target stays at
/// macOS 13 since DevOverlay is opt-in.
/// Holds the three slices listed in the file header. Each slice has its
/// own mutator (`apply`, `updateBootHealth`, `updateHudState`) so the
/// callers stay narrowly typed and the single-writer invariants are
/// inspectable by `grep`.
///
/// ## Threading
///
/// `@MainActor`-isolated. All three mutators must be called on the main
/// actor — the SwiftUI Observation framework re-renders on the actor that
/// wrote the property. Calling from a background context would crash in
/// strict-concurrency mode.
@MainActor
@Observable
public final class DevOverlayViewModel {
// ME-02: `private(set)` enforces single-write-path by type, not
// convention. `DevOverlayBridge`'s subscriber Task routes writes
// through `apply(_:)`. Without this scope restriction any caller
// could `vm.snapshot = …` directly and break the AGENT-10 invariant.
/// DevSnapshot from `DevSnapshotEmitter`. Single-writer: bridge task.
public private(set) var snapshot: DevSnapshot
/// Last cached boot-health snapshot. `nil` until the first probe runs.
public private(set) var bootHealth: BootHealthSnapshot?
/// Display string for the current HUD state (e.g., "idle", "thinking").
/// String rather than the enum because `HudState` lives in the App
/// target, not in any SPM package the overlay can import.
public private(set) var hudStateLabel: String = "—"
public init(snapshot: DevSnapshot = .initial) {
self.snapshot = snapshot
}
/// Replace the current snapshot. Called by `DevOverlayBridge`'s subscriber
/// task on the main actor. The last-5 tool-call ring is maintained by
/// `DevSnapshotEmitter`, so this method is pure overwrite.
/// Replace the current snapshot. Called by `DevOverlayBridge`'s
/// subscriber task on the main actor.
public func apply(_ newSnapshot: DevSnapshot) {
self.snapshot = newSnapshot
}
/// Reset to `.initial` — used by tests and by the app shell when the
/// orchestrator rebinds (e.g., session switch).
/// Replace the cached boot-health snapshot. Called by
/// `DevOverlayWindow`'s poll loop.
public func updateBootHealth(_ snap: BootHealthSnapshot?) {
self.bootHealth = snap
}
/// Replace the HUD-state display string.
public func updateHudState(_ label: String) {
self.hudStateLabel = label
}
/// Reset all three slices. Used by tests and by the app shell when
/// the orchestrator rebinds (e.g., session switch).
public func reset() {
self.snapshot = .initial
self.bootHealth = nil
self.hudStateLabel = "—"
}
}
@@ -1,33 +1,89 @@
// DevOverlayWindow.swift
//
// NSPanel host for the developer-facing diagnostic surface. Renders four
// panes across a TabView: live agent snapshot, last-5 tool calls, token
// budget + latency, and subsystem health. Each pane is read-only — the
// window is a window into runtime state, never a controller.
//
// Wiring (Slice 2 of the dev-overlay-end-to-end fix, 2026-05-12):
// 1. AppDelegate constructs a `DevSnapshotEmitter` in `installAgent`
// and forwards every `.devOverlay`-priority broadcaster event to it.
// 2. AppDelegate calls `DevOverlayWindow(emitter:, bootHealth:)` so the
// window's `DevOverlayBridge` can subscribe to the emitter's bounded
// output channel.
// 3. BootHealth + HudState are read on a 1 Hz timer (polling beats
// adding yet another subscriber for a low-frequency surface).
//
// Before you edit:
// - The `viewModel.apply(_:)` write path must remain single-writer (the
// bridge subscriber). Don't add direct writes elsewhere.
// - Window construction is lazy on first menu click — moving the
// emitter dependency to a stored property keeps the panel "cold" until
// a developer actually opens it.
//
// See also: DevOverlayBridge.swift (channel pump),
// packages/AgentCore/Sources/AgentOrchestrator/DevSnapshotEmitter.swift,
// App/MenuBar/StatusPanel.swift (sibling NSPanel pattern).
#if canImport(AppKit) && canImport(SwiftUI)
import AppKit
import SwiftUI
import AgentCore
import AgentOrchestrator
/// NSPanel wrapper hosting `DevOverlayView`.
/// Floating, non-modal panel hosting `DevOverlayView`.
///
/// - `.floating` window level so the overlay stays above the main HUD.
/// - `.nonactivatingPanel` style so clicking the overlay never steals focus
/// from the app the developer is inspecting.
/// - Transparent background + `.regularMaterial` comes from the SwiftUI view.
/// - Hidden by default; `show()` / `hide()` / `toggle()` drive visibility.
/// ## Overview
///
/// **Menu-bar wiring is not part of this plan** — Plan 01-04 (or the App-shell
/// plan in Phase 8 hardening) binds the toggle to a menu-bar item + global
/// hotkey. This class exposes the toggle primitive only.
/// `.floating` window level keeps the overlay above the main HUD without
/// stealing focus (`.nonactivatingPanel`). The panel is constructed lazily
/// on first menu-click and survives close/reopen cycles
/// (`isReleasedWhenClosed = false`).
///
/// ## Topics
///
/// - ``init(emitter:bootHealthOrchestrator:hudStateReader:)``
/// - ``show()``
/// - ``hide()``
/// - ``toggle()``
///
/// ## Threading
///
/// `@MainActor`-isolated. The bridge subscriber task hops to the main
/// actor on each delivery to keep the SwiftUI write path on its expected
/// executor.
@available(macOS 14.0, *)
@MainActor
public final class DevOverlayWindow {
private let panel: NSPanel
public let viewModel: DevOverlayViewModel
private let bridge: DevOverlayBridge
private let bootHealthOrchestrator: BootHealthOrchestrator?
private let hudStateReader: (@MainActor () -> String)?
private var pollTimer: Task<Void, Never>?
public init(viewModel: DevOverlayViewModel = DevOverlayViewModel()) {
/// Wired-up initializer. AppDelegate calls this with the live emitter
/// + boot-health orchestrator + a closure that reads the current
/// `HudState` off `HudStateCoordinator` (which is App-target-only, so
/// we accept a closure rather than imposing a dependency on the
/// coordinator type).
public init(
emitter: DevSnapshotEmitter?,
bootHealthOrchestrator: BootHealthOrchestrator? = nil,
hudStateReader: (@MainActor () -> String)? = nil,
viewModel: DevOverlayViewModel = DevOverlayViewModel()
) {
self.viewModel = viewModel
let contentRect = NSRect(x: 0, y: 0, width: 460, height: 360)
self.bridge = DevOverlayBridge(viewModel: viewModel)
self.bootHealthOrchestrator = bootHealthOrchestrator
self.hudStateReader = hudStateReader
let contentRect = NSRect(x: 0, y: 0, width: 560, height: 520)
let styleMask: NSWindow.StyleMask = [
.titled,
.utilityWindow,
.nonactivatingPanel,
.closable,
.resizable,
.nonactivatingPanel,
]
let p = NSPanel(
contentRect: contentRect,
@@ -43,16 +99,32 @@ public final class DevOverlayWindow {
p.title = "Jarvis DevOverlay"
p.isReleasedWhenClosed = false
p.contentView = NSHostingView(rootView: DevOverlayView(viewModel: viewModel))
p.orderOut(nil) // hidden by default
p.orderOut(nil)
self.panel = p
// Subscribe the bridge to the emitter's bounded output channel.
// When `emitter` is nil (Slice-2 partial wiring, dev-only tooling
// construction, or tests), the panel still renders `.initial` and
// boot-health updates flow normally.
if let emitter {
Task { @MainActor [bridge] in
let output = await emitter.output
bridge.attach(channel: output)
}
}
}
/// Show the panel + start the 1 Hz poll for BootHealth and HudState.
/// Polling only runs while visible, so a hidden DevOverlay imposes
/// zero runtime cost.
public func show() {
panel.makeKeyAndOrderFront(nil)
startPolling()
}
public func hide() {
panel.orderOut(nil)
stopPolling()
}
public var isVisible: Bool { panel.isVisible }
@@ -60,5 +132,39 @@ public final class DevOverlayWindow {
public func toggle() {
if isVisible { hide() } else { show() }
}
// MARK: - Polling (BootHealth + HudState)
/// 1 Hz read loop. BootHealth is queried via `lastSnapshot()` (no
/// re-probe — that's the Status panel's job; here we just observe).
/// HudState comes from the App-target reader closure. Both are
/// cheap: actor read + enum copy.
private func startPolling() {
stopPolling()
let bh = bootHealthOrchestrator
let hr = hudStateReader
let vm = viewModel
pollTimer = Task { @MainActor [weak vm] in
while !Task.isCancelled {
if let bh {
let snap = await bh.lastSnapshot()
vm?.updateBootHealth(snap)
}
if let hr {
vm?.updateHudState(hr())
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
}
private func stopPolling() {
pollTimer?.cancel()
pollTimer = nil
}
deinit {
pollTimer?.cancel()
}
}
#endif