From ccc991614f8b203718cc139840e196b2f398c7c9 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sun, 12 Jul 2026 13:17:34 -0700 Subject: [PATCH] fix(dashboard): persist the status strip across tab swaps (JEF-410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the client-only move (JEF-408) the strip was read from the per-tab payload (`state.data.strip`), and `setActiveTab` nulls `data` on a swap to drop the old tab's body — which also blanked the strip until the new tab's snapshot landed, so the persistent honesty header tore down and repainted on every tab switch. Decouple the strip from the per-tab body: the store now holds a dedicated `strip` field, updated from every snapshot's global posture and held across a tab swap (`setActiveTab` clears only `data`). `app.jsx` renders ``. The header is drawn once and reconciles in place; a tab switch no longer blanks it. First-paint (no snapshot yet) still renders nothing — blank is honest (absent ≠ green). Tests: the strip updates from each snapshot and survives a tab swap; a strip-less snapshot never regresses the header to blank. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- engine/web/src/app.jsx | 10 ++++++---- engine/web/src/store.js | 14 +++++++++++--- engine/web/test/store.test.js | 25 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/engine/web/src/app.jsx b/engine/web/src/app.jsx index d5ea4fe..46cd27a 100644 --- a/engine/web/src/app.jsx +++ b/engine/web/src/app.jsx @@ -52,10 +52,12 @@ export function App({ store, liveRegion }) { const state = store.getState(); return (
- {/* The persistent status strip — the honesty spine on every view. Before the first snapshot - lands there is no strip data, so nothing renders here (blank is honest — absent is never a - green all-clear; the ConnectionBanner already says "connecting…"). */} - + {/* The persistent status strip — the honesty spine on every view. Read from the store's + decoupled `strip` (global posture), NOT the per-tab `data`, so a tab-swap never tears it + down; it stays mounted and reconciles in place as posture changes. Before the first + snapshot there is no strip, so nothing renders (blank is honest — absent is never a green + all-clear; the ConnectionBanner already says "connecting…"). */} + diff --git a/engine/web/src/store.js b/engine/web/src/store.js index 4a7c92a..643073d 100644 --- a/engine/web/src/store.js +++ b/engine/web/src/store.js @@ -67,13 +67,17 @@ export class Store { /** @type {Set<() => void>} */ this.listeners = new Set(); /** @type {{ activeTab: string, expandedRows: Set, openPrompts: Set, - * data: unknown, status: Status, lastGoodAt: number | null }} */ + * data: unknown, strip: unknown, status: Status, lastGoodAt: number | null }} */ this.state = { activeTab: seed.activeTab || "findings", expandedRows: loadSet(EXPANDED_KEY), openPrompts: loadSet(PROMPT_KEY), disclosures: loadSet(DISCLOSURE_KEY), data: null, + // The persistent status strip (global cluster posture), decoupled from the per-tab `data` + // so a tab-swap (which nulls `data`) never tears the header down. Updated on every snapshot; + // held across swaps. Null before the first snapshot (blank is honest — absent is never green). + strip: null, status: "first-load", lastGoodAt: null, }; @@ -104,7 +108,10 @@ export class Store { * @param {unknown} data */ applySnapshot(data) { - this.state = { ...this.state, data, status: "live", lastGoodAt: Date.now() }; + // Refresh the persistent strip from this snapshot's global posture (present in every tab's + // payload). Keep the last strip if a snapshot somehow omits it, so the header never blanks. + const strip = data && data.strip ? data.strip : this.state.strip; + this.state = { ...this.state, data, strip, status: "live", lastGoodAt: Date.now() }; this.emit(); } @@ -215,7 +222,8 @@ export class Store { // Drop the previous tab's snapshot: each tab has its own JSON shape, so rendering the old // snapshot under the new view would be wrong. The next poll (repointed to the new tab) lands its // snapshot; until then the view shows its quiet first-paint body, and the status remains LIVE - // (this is a local view change, not a connection problem). + // (this is a local view change, not a connection problem). `strip` is deliberately NOT cleared — + // it is global posture that must persist across the swap so the header never tears down. this.state = { ...this.state, activeTab: tab, data: null }; this.emit(); } diff --git a/engine/web/test/store.test.js b/engine/web/test/store.test.js index 9fb6ac7..89a0b18 100644 --- a/engine/web/test/store.test.js +++ b/engine/web/test/store.test.js @@ -97,3 +97,28 @@ describe("subscribe", () => { expect(n).toBe(1); }); }); + +describe("persistent status strip (JEF-410)", () => { + it("updates the strip from each snapshot and holds it across a tab swap", () => { + const store = new Store(); + expect(store.getState().strip).toBe(null); // no snapshot yet — blank is honest + + const strip = { "all-clear": false, "judging-state": "watching" }; + store.applySnapshot({ strip, rows: [] }); + expect(store.getState().strip).toBe(strip); + expect(store.getState().data).toEqual({ strip, rows: [] }); + + // A tab swap drops the per-tab body but MUST NOT tear down the global strip. + store.setActiveTab("alerts"); + expect(store.getState().data).toBe(null); // body cleared for the new tab + expect(store.getState().strip).toBe(strip); // header persists — no blank/redraw + }); + + it("keeps the last strip if a later snapshot omits it", () => { + const store = new Store(); + const strip = { "all-clear": true, "judging-state": "all-clear" }; + store.applySnapshot({ strip, rows: [] }); + store.applySnapshot({ rows: [] }); // malformed / strip-less snapshot + expect(store.getState().strip).toBe(strip); // never regress the header to blank + }); +});