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
KofTwentyTwo commented 2026-05-12 21:31:39 +00:00 (Migrated from github.com)

Summary

Closes the v0.1 Bus/HUD S1 cluster — two critical audit findings from
.planning/audit-2026-05-12/bus-hud-audit.md that broke basic UI signals:

  • #40 — BridgeNavigationDelegate had no didFail* arm. A 404 on
    the JS bundle or any pre-mount JS error left the handshake stuck in
    .idle forever; the 2 s HandshakeTiming.timeout only armed from
    didFinish, so the user saw a silent black HUD with no banner and no
    hard-block. Now both didFailProvisionalNavigation and didFail
    route through WebviewBridge.failHandshake(reason:) → new terminal
    HandshakeState.loadFailed(reason:) + a critical-priority
    non-dismissible HUDBannerCoordinator enqueue (same pipeline the
    Round 4 bootHealthCritical banners use).
  • #41 — MenuBarIconController.transition(to:) was
    production-dead. The full CABasicAnimation surface (breath / rotate /
    shimmer / glow) shipped and was unit-tested, but
    HudStateCoordinator's emit closure only fanned state to the bus; the
    menu-bar icon never animated. Expanded the emit closure to also call
    menuBarController?.transition(to: appState) so the icon follows
    agent state.

Two commits, one per issue.

What changed

Issue #40 (fix(bus): surface webview load failures…)

  • packages/Bus/Sources/Bus/Handshake.swift — added HandshakeState.loadFailed(reason:) terminal case.
  • packages/Bus/Sources/Bus/WebviewBridge.swift — added failHandshake(reason:) (idempotent, cancels timeout, alerts).
  • App/AppDelegate.swift — added onDidFail to BridgeNavigationDelegate; new handleWebviewLoadFailure(_:) routes to failHandshake + critical banner.
  • App/Boot/BootHealthProbes.swift — new switch arm for .loadFailed in WebviewBootHealthProbe.
  • Tests: HandshakeTests +4; BridgeNavigationFailureTests (new App-level file) — drives both didFailProvisionalNavigation and didFail arms + the end-to-end handleWebviewLoadFailure path.

Issue #41 (fix(hud): fan HudState emit to MenuBarIconController…)

  • App/AppDelegate.swift — emit closure in installBus() now fans to both bridge.send(.hudState(...)) and menuBarController?.transition(to: appState).
  • Test: MenuBarIconStateTransitionTests (new) — drives the live coordinator via dormantAgentContinuation, asserts icon state advances through .thinking → .speaking → .idle.

Invariants preserved

  • scripts/check-single-writer-hudstate.sh — GREEN. transition(to:) writes the icon's currentState, not BusOutbound.hudState; the single-writer invariant only covers the .hudState(...) constructor surface.
  • scripts/check-no-evaluate-javascript.sh — GREEN. No new JS surfaces.
  • scripts/check-no-modal-presentation.sh — GREEN. Hard-block alert continues to route through allowlisted TCCAlertService.presentHardBlock.
  • All 18 boundary gates pass.

Test plan

  • swift test --package-path packages/Bus — 62 tests pass (+4 new failHandshake cases).
  • bash scripts/check-app-builds.sh — App target compiles cleanly.
  • All 18 boundary gates pass (verified via manual sweep).
  • Manual: rename webview/index.html → index.html.bak and launch — verify a critical non-dismissible banner appears within ~100 ms (not after 2 s) instead of a silent black HUD.
  • Manual: observe menu-bar icon while submitting a turn — verify breath (listening) → rotate (thinking) → shimmer (speaking) transitions.

Notes for review

  • The hard-block alert (TCCAlertService.presentHardBlock) and the HUDBannerCoordinator banner BOTH fire on load failure — defense-in-depth. The alert is the existing path that already terminates on handshake-mismatch / timeout; the banner survives even if the alert is silenced (e.g., AppDelegate test seam with a no-op alertPresenter).
  • BridgeNavigationDelegate is now internal (not private) so tests in App/Tests/AppTests/ can drive its protocol methods directly via @testable import Jarvis. No other production code references it.

🤖 Generated with Claude Code

## Summary Closes the v0.1 Bus/HUD S1 cluster — two critical audit findings from `.planning/audit-2026-05-12/bus-hud-audit.md` that broke basic UI signals: - **#40** — `BridgeNavigationDelegate` had no `didFail*` arm. A 404 on the JS bundle or any pre-mount JS error left the handshake stuck in `.idle` forever; the 2 s `HandshakeTiming.timeout` only armed from `didFinish`, so the user saw a silent black HUD with no banner and no hard-block. Now both `didFailProvisionalNavigation` and `didFail` route through `WebviewBridge.failHandshake(reason:)` → new terminal `HandshakeState.loadFailed(reason:)` + a critical-priority non-dismissible `HUDBannerCoordinator` enqueue (same pipeline the Round 4 `bootHealthCritical` banners use). - **#41** — `MenuBarIconController.transition(to:)` was production-dead. The full CABasicAnimation surface (breath / rotate / shimmer / glow) shipped and was unit-tested, but `HudStateCoordinator`'s emit closure only fanned state to the bus; the menu-bar icon never animated. Expanded the emit closure to also call `menuBarController?.transition(to: appState)` so the icon follows agent state. Two commits, one per issue. ## What changed ### Issue #40 (`fix(bus): surface webview load failures…`) - `packages/Bus/Sources/Bus/Handshake.swift` — added `HandshakeState.loadFailed(reason:)` terminal case. - `packages/Bus/Sources/Bus/WebviewBridge.swift` — added `failHandshake(reason:)` (idempotent, cancels timeout, alerts). - `App/AppDelegate.swift` — added `onDidFail` to `BridgeNavigationDelegate`; new `handleWebviewLoadFailure(_:)` routes to `failHandshake` + critical banner. - `App/Boot/BootHealthProbes.swift` — new switch arm for `.loadFailed` in `WebviewBootHealthProbe`. - Tests: `HandshakeTests` +4; `BridgeNavigationFailureTests` (new App-level file) — drives both `didFailProvisionalNavigation` and `didFail` arms + the end-to-end `handleWebviewLoadFailure` path. ### Issue #41 (`fix(hud): fan HudState emit to MenuBarIconController…`) - `App/AppDelegate.swift` — emit closure in `installBus()` now fans to both `bridge.send(.hudState(...))` and `menuBarController?.transition(to: appState)`. - Test: `MenuBarIconStateTransitionTests` (new) — drives the live coordinator via `dormantAgentContinuation`, asserts icon state advances through `.thinking → .speaking → .idle`. ## Invariants preserved - `scripts/check-single-writer-hudstate.sh` — GREEN. `transition(to:)` writes the icon's `currentState`, not `BusOutbound.hudState`; the single-writer invariant only covers the `.hudState(...)` constructor surface. - `scripts/check-no-evaluate-javascript.sh` — GREEN. No new JS surfaces. - `scripts/check-no-modal-presentation.sh` — GREEN. Hard-block alert continues to route through allowlisted `TCCAlertService.presentHardBlock`. - All 18 boundary gates pass. ## Test plan - [x] `swift test --package-path packages/Bus` — 62 tests pass (+4 new `failHandshake` cases). - [x] `bash scripts/check-app-builds.sh` — App target compiles cleanly. - [x] All 18 boundary gates pass (verified via manual sweep). - [ ] **Manual:** rename `webview/index.html` → `index.html.bak` and launch — verify a critical non-dismissible banner appears within ~100 ms (not after 2 s) instead of a silent black HUD. - [ ] **Manual:** observe menu-bar icon while submitting a turn — verify breath (listening) → rotate (thinking) → shimmer (speaking) transitions. ## Notes for review - The hard-block alert (`TCCAlertService.presentHardBlock`) and the HUDBannerCoordinator banner BOTH fire on load failure — defense-in-depth. The alert is the existing path that already terminates on handshake-mismatch / timeout; the banner survives even if the alert is silenced (e.g., AppDelegate test seam with a no-op `alertPresenter`). - `BridgeNavigationDelegate` is now `internal` (not `private`) so tests in `App/Tests/AppTests/` can drive its protocol methods directly via `@testable import Jarvis`. No other production code references it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.