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:
4 files changed
+533
-109
No files matched your search
+57
-10
@@ -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
|
||||
Reference in new issue
Block a user