fix(bus,hud): v0.1 S1 cluster — webview didFail + menu-bar icon transition #151

Merged
KofTwentyTwo merged 2 commits from fix/bus-hud-s1-v0.1 into develop 2026-05-13 11:29:34 +00:00
2 Commits
Author SHA1 Message Date
james.maesandClaude Opus 4.7 2bfe53f475 fix(hud): fan HudState emit to MenuBarIconController so icon animates per state — closes #41
`MenuBarIconController.transition(to:)` shipped with a full per-state
CABasicAnimation surface (breath / rotate / shimmer / glow / idle) and
was exhaustively unit-tested, but no production code ever called it.
`HudStateCoordinator`'s emit closure only fanned state changes to
`WebviewBridge.send(.hudState(_:))` — the menu-bar icon stayed in its
default static state for the entire app lifetime regardless of
`.listening` / `.thinking` / `.speaking` activity. Only `applyHealth(_:)`
(Round 4 boot-health tint) was wired.

This expands the emit closure in `installBus()` to ALSO call
`menuBarController?.transition(to: appState)` on every distinct resolved
HudState. The bus dispatch stays in a `Task { @MainActor in … }` (because
`bridge.send` is async); the icon transition is a direct main-actor call
(no await needed). `[weak bridge, weak self]` preserves the existing
retain-cycle hygiene.

Not a HUD-08 single-writer violation: `transition(to:)` writes the
icon's `currentState`, not `BusOutbound.hudState`. The single-writer
invariant covers the `.hudState(...)` constructor surface, not arbitrary
subscribers. `scripts/check-single-writer-hudstate.sh` continues to pass.

Test:
  - `MenuBarIconStateTransitionTests` (App package, new file): drives
    the live coordinator via the AppDelegate's retained
    `dormantAgentContinuation`, arms the handshake to promote the
    coordinator off `.booting`, yields `.thinking → .speaking → .idle`,
    asserts `menuBarController.state` advances on each transition.

Refs audit-2026-05-12 bus-hud-audit.md §S1 (MenuBarIconController).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 16:30:22 -05:00
james.maesandClaude Opus 4.7 fc7b473afe fix(bus): surface webview load failures via failHandshake + critical banner — closes #40
`BridgeNavigationDelegate` only implemented `didFinish`. A 404 on the JS
bundle (`<script src="./assets/index-XXX.js">`), a JS parse error before
mount, or any pre-mount navigation failure left the handshake stuck in
`.idle` forever — `WebviewBridge.startHandshake()` (and its 2-second
timeout) only run from `didFinish`, so the user saw a silent black HUD
with no banner and no hard-block.

This adds the two failure arms (`didFailProvisionalNavigation` and
`didFail`) and routes them through:

  1. `WebviewBridge.failHandshake(reason:)` — new public surface that
     transitions the handshake state to the new terminal
     `HandshakeState.loadFailed(reason:)` case. Cancels the pending 2s
     timeout (so a late timeout-alert doesn't pile on after the
     load-failed alert) and fires the same `alertPresenter` the
     `.timedOut` and `.mismatched` paths already use (production wiring:
     `TCCAlertService.presentHardBlock` + AppDelegate's terminate hook).
     Idempotent — `.armed`/`.mismatched`/`.timedOut`/`.loadFailed` are
     terminal, so re-entry is a no-op.

  2. `HUDBannerCoordinator.enqueue(.bootHealthCritical(subsystem:
     "webview", ...))` via the new `AppDelegate.handleWebviewLoadFailure`
     hop. Same enqueue pipeline as the Round 4 critical-subsystem
     banners (memory off, no API key, no MCP tools, webview never
     armed); priority 1, non-dismissible, native NSPanel so the surface
     survives even when the webview itself is the broken subsystem.

`WebviewBootHealthProbe` gains a switch arm for the new `.loadFailed`
case (severity `.critical`, matching the existing `.timedOut`/`.mismatched`
treatment) so the boot-health summary reflects the failure.

Tests:
  - `HandshakeTests` (Bus package, +4 tests): `failHandshake` from
    `.sentHello` transitions to `.loadFailed` + cancels the 2s timeout
    + alerts exactly once; from `.idle` still transitions; idempotent
    on re-entry; no-ops after `.armed`.
  - `BridgeNavigationFailureTests` (App package, new file): drives
    `didFailProvisionalNavigation` + `didFail` directly on the delegate
    + the AppDelegate-level `handleWebviewLoadFailure` end-to-end
    (bridge transitions + critical banner enqueued + non-dismissible).

Refs audit-2026-05-12 bus-hud-audit.md §S1 (BridgeNavigationDelegate).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 16:28:50 -05:00