Score manipulation pressure, evolve the firewall, and rehearse against the red team.
+
+
+
+
+ Loading…
}>
+
+
+
+
+
+ Rule editor
+
+
+
+
+
+
+
+
+ Red team & evolve
+
+
+
+
+
+
+
+
+ Investigations
+
+
+
+
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/shell/workspaces/HomeWorkspace.jsx b/brainsnn-r3f-app/src/shell/workspaces/HomeWorkspace.jsx
new file mode 100644
index 0000000..8a340a4
--- /dev/null
+++ b/brainsnn-r3f-app/src/shell/workspaces/HomeWorkspace.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import ControlsBar from '../../components/ControlsBar';
+import DemoTiles from '../../components/DemoTiles';
+
+/**
+ * Home — landing workspace. Run controls + demo tiles for first-time
+ * users. The brain itself lives in BrainViewport which is rendered
+ * by AppShell and visible from every workspace.
+ */
+export default function HomeWorkspace({ session }) {
+ const { state, mode, quality, isRecording, exportStatus, gifOptions, onControls, onDemo } = session;
+
+ return (
+
+
+
Welcome back
+
The brain is listening.
+
+ Drop a tweet, an email, a transcript, or a screenshot. Watch the brain react,
+ firewall score the manipulation pressure, and 100+ cognitive layers light up.
+ Pick a workspace on the left to go deeper.
+
-
- );
-}
diff --git a/brainsnn-r3f-app/src/main.jsx b/brainsnn-r3f-app/src/main.jsx
index c53e577..833c564 100644
--- a/brainsnn-r3f-app/src/main.jsx
+++ b/brainsnn-r3f-app/src/main.jsx
@@ -6,21 +6,20 @@ import './styles/tokens.css';
import './styles/global.css';
import './styles/shell.css';
-// Feature flag — ?shell=new switches to the Claude-design AppShell.
-// Both shells share the same providers, panels, and persistence; only
-// one mounts at a time to avoid double-registering the MCP bridge and
-// dream monitor.
+// Default shell flipped to the Claude-design AppShell. Legacy shell
+// kept reachable via ?shell=old for one release as an escape hatch
+// while users adjust; will be removed in a follow-up PR.
function pickShell() {
try {
const url = new URLSearchParams(window.location.search);
- if (url.get('shell') === 'new') return 'new';
if (url.get('shell') === 'old') return 'old';
- if (localStorage.getItem('brainsnn_shell_pref') === 'new') return 'new';
+ if (url.get('shell') === 'new') return 'new';
+ if (localStorage.getItem('brainsnn_shell_pref') === 'old') return 'old';
} catch { /* noop */ }
- return 'old';
+ return 'new';
}
-const ShellRoot = pickShell() === 'new' ? NewApp : App;
+const ShellRoot = pickShell() === 'old' ? App : NewApp;
ReactDOM.createRoot(document.getElementById('root')).render(
From 0494571715e7f32af1c3efb49007ed941c225097 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 17 May 2026 20:42:15 +0000
Subject: [PATCH 07/33] =?UTF-8?q?feat(engine):=20transformers.js=20MiniLM?=
=?UTF-8?q?=20into=20a=20worker;=20embeddings=20cache=20=E2=86=92=20IDB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Beast PR #8 from the plan. Moves the ~25MB Xenova/all-MiniLM-L6-v2
model load and inference off the main thread, and replaces the
500-entry localStorage cache with an IDB-backed LRU store that scales
to 5000+ vectors without quota errors.
New files:
* src/workers/embeddings.worker.js — loads transformers.js from CDN
inside the worker, exposes warmup / getStatus / embed / embedBatch
over the existing workerPool envelope. Pool size 1 (single model
instance is enough; embeds are sequential per worker anyway).
* src/utils/embeddingsStore.js — IDB cache via openStore('embeddings').
Stores { vec: Float32Array, t: timestamp }; refreshes `t` on read
so LRU eviction stays accurate. Soft cap 5000 / prune-to 4500,
amortized prune every ~100 writes. One-shot legacy migrator copies
brainsnn_embeddings_v1 (cap-500 localStorage blob) into IDB on
first read, sets brainsnn_embeddings_migrated_v1, leaves the old
key in place for one release as rollback safety.
Rewritten:
* src/utils/embeddings.js — thin facade over worker + IDB cache.
Public API unchanged (initEmbeddings, embed, embedBatch, isReady,
subscribeStatus, getEmbeddingStatus, clearEmbeddingCache,
cosineSimilarity). New: cacheSize() helper for Privacy Budget.
Status polling (600ms) only runs while loading; stops once ready
or errored. Inline fallback for Worker-less environments (SSR,
test env). Idle warm-prefetch on returning visits: marks the user
"seen" once the model reaches ready state; next visit kicks off
initEmbeddings() under requestIdleCallback so the first scan that
needs embeddings doesn't pay the 2-3s cold-load tax.
Behavior parity: every caller (RAG, semantic templates, code search,
similarity search) continues to work without changes. The worker is
spawned lazily on first init/embed, so apps that never touch
embeddings still pay zero cost.
Build verified: embeddings.worker-*.js emits as a 1.24kB chunk; main
JS bundle unchanged (heavy CDN load happens at runtime inside worker).
---
brainsnn-r3f-app/src/utils/embeddings.js | 311 ++++++++++++------
brainsnn-r3f-app/src/utils/embeddingsStore.js | 114 +++++++
.../src/workers/embeddings.worker.js | 73 ++++
3 files changed, 390 insertions(+), 108 deletions(-)
create mode 100644 brainsnn-r3f-app/src/utils/embeddingsStore.js
create mode 100644 brainsnn-r3f-app/src/workers/embeddings.worker.js
diff --git a/brainsnn-r3f-app/src/utils/embeddings.js b/brainsnn-r3f-app/src/utils/embeddings.js
index afedec8..39add21 100644
--- a/brainsnn-r3f-app/src/utils/embeddings.js
+++ b/brainsnn-r3f-app/src/utils/embeddings.js
@@ -1,164 +1,259 @@
/**
- * Layer 24 — Real embeddings via transformers.js (CDN-loaded)
+ * Layer 24 — Real embeddings via transformers.js.
*
- * Provider pattern: trigram Jaccard fallback by default, opt-in to real
- * semantic embeddings via @xenova/transformers loaded dynamically from
- * esm.run CDN. No build-time dep — first call fetches the library +
- * `Xenova/all-MiniLM-L6-v2` model (~25MB).
+ * Facade over the embeddings worker (workers/embeddings.worker.js)
+ * and the IDB cache (utils/embeddingsStore.js). Public API matches
+ * the original synchronous-style contract so every caller keeps
+ * working without changes:
*
- * Cached embeddings live in memory and are persisted to localStorage
- * (scoped by text hash) so repeat queries are instant.
+ * await initEmbeddings()
+ * await embed(text) -> Float32Array
+ * await embedBatch(texts) -> Float32Array[]
+ * isReady() -> boolean
+ * subscribeStatus(cb)
+ * getEmbeddingStatus()
+ * clearEmbeddingCache()
+ *
+ * Heavy work runs in the worker; cache reads/writes happen on the main
+ * thread against IDB. Falls back to inline (main-thread) loading if
+ * Workers aren't available (SSR, test env, ancient browsers).
*/
+import { createPool } from './workerPool';
+import { getCached, setCached, clearCache, cacheSize } from './embeddingsStore';
+
const CDN_URL = 'https://esm.run/@xenova/transformers@2.17.2';
const MODEL_ID = 'Xenova/all-MiniLM-L6-v2';
-const CACHE_KEY = 'brainsnn_embeddings_v1';
-const CACHE_MAX = 500;
-
-let pipelinePromise = null;
-let pipelineInstance = null;
-let status = { state: 'idle', error: null, modelId: MODEL_ID, cdn: CDN_URL };
-const subscribers = new Set();
-const memCache = new Map(); // textHash → Float32Array
-// ---------- status broadcast ----------
+let _pool = null;
+let _statusPollTimer = null;
+let _status = { state: 'idle', error: null, modelId: MODEL_ID, cdn: CDN_URL };
+const _subs = new Set();
-function setStatus(patch) {
- status = { ...status, ...patch };
- for (const cb of subscribers) {
- try { cb(status); } catch { /* ignore */ }
+function broadcast(patch) {
+ _status = { ..._status, ...patch };
+ for (const cb of _subs) {
+ try { cb(_status); } catch { /* noop */ }
}
}
-export function subscribeStatus(cb) {
- subscribers.add(cb);
- cb(status);
- return () => subscribers.delete(cb);
+function ensurePool() {
+ if (_pool) return _pool;
+ if (typeof window === 'undefined') return null;
+ try {
+ _pool = createPool(
+ () => new Worker(new URL('../workers/embeddings.worker.js', import.meta.url), { type: 'module' }),
+ {
+ size: 1, // single model instance is fine; embeds are sequential per worker
+ fallback: null // inline path is handled separately in initInline()
+ }
+ );
+ return _pool;
+ } catch {
+ return null;
+ }
}
-export function getEmbeddingStatus() {
- return { ...status, cacheSize: memCache.size };
-}
+// ---------- inline fallback (if Worker unavailable) ----------
-// ---------- cache ----------
+let _inlinePromise = null;
+let _inlineInstance = null;
-function loadCache() {
- try {
- const raw = localStorage.getItem(CACHE_KEY);
- if (!raw) return;
- const parsed = JSON.parse(raw);
- for (const [hash, arr] of Object.entries(parsed)) {
- memCache.set(hash, Float32Array.from(arr));
+async function inlinePipeline() {
+ if (_inlineInstance) return _inlineInstance;
+ if (_inlinePromise) return _inlinePromise;
+ broadcast({ state: 'loading' });
+ _inlinePromise = (async () => {
+ try {
+ const mod = await import(/* @vite-ignore */ CDN_URL);
+ broadcast({ state: 'model-loading' });
+ _inlineInstance = await mod.pipeline('feature-extraction', MODEL_ID, { quantized: true });
+ broadcast({ state: 'ready', error: null });
+ return _inlineInstance;
+ } catch (err) {
+ broadcast({ state: 'error', error: err.message || String(err) });
+ _inlinePromise = null;
+ throw err;
}
- } catch { /* ignore */ }
+ })();
+ return _inlinePromise;
}
-function saveCache() {
- try {
- const obj = {};
- let count = 0;
- for (const [hash, vec] of memCache) {
- if (count++ >= CACHE_MAX) break;
- obj[hash] = Array.from(vec);
- }
- localStorage.setItem(CACHE_KEY, JSON.stringify(obj));
- } catch { /* quota — ignore */ }
-}
+// ---------- status polling (worker path) ----------
-export function clearEmbeddingCache() {
- memCache.clear();
- try { localStorage.removeItem(CACHE_KEY); } catch { /* ignore */ }
- setStatus({});
+function startStatusPolling() {
+ if (_statusPollTimer) return;
+ _statusPollTimer = setInterval(async () => {
+ const pool = ensurePool();
+ if (!pool || pool.degraded) return;
+ try {
+ const s = await pool.call('getStatus', {});
+ broadcast({ state: s.state, error: s.error });
+ if (s.state === 'ready' || s.state === 'error') stopStatusPolling();
+ } catch {
+ stopStatusPolling();
+ }
+ }, 600);
}
-// Fast non-crypto hash for cache keys
-function hashText(text) {
- let h = 2166136261;
- for (let i = 0; i < text.length; i++) {
- h ^= text.charCodeAt(i);
- h = Math.imul(h, 16777619);
+function stopStatusPolling() {
+ if (_statusPollTimer) {
+ clearInterval(_statusPollTimer);
+ _statusPollTimer = null;
}
- return (h >>> 0).toString(36);
}
-// ---------- model loading ----------
+// ---------- public API ----------
-/**
- * Load transformers.js from CDN + the embedding model.
- * Idempotent — safe to call multiple times.
- */
-export async function initEmbeddings() {
- if (pipelineInstance) return pipelineInstance;
- if (pipelinePromise) return pipelinePromise;
+export function subscribeStatus(cb) {
+ _subs.add(cb);
+ cb(_status);
+ return () => _subs.delete(cb);
+}
- loadCache();
- setStatus({ state: 'loading', error: null });
+export function getEmbeddingStatus() {
+ return { ..._status };
+}
- pipelinePromise = (async () => {
+export function isReady() {
+ return _status.state === 'ready';
+}
+
+export async function initEmbeddings() {
+ const pool = ensurePool();
+ if (pool && !pool.degraded) {
+ broadcast({ state: 'loading' });
+ startStatusPolling();
try {
- const mod = await import(/* @vite-ignore */ CDN_URL);
- setStatus({ state: 'model-loading' });
- pipelineInstance = await mod.pipeline('feature-extraction', MODEL_ID, {
- quantized: true
- });
- setStatus({ state: 'ready', error: null });
- return pipelineInstance;
+ await pool.call('warmup', {});
} catch (err) {
- setStatus({ state: 'error', error: err.message || String(err) });
- pipelinePromise = null;
- throw err;
+ broadcast({ state: 'error', error: err.message || String(err) });
}
- })();
+ return;
+ }
+ // Worker unavailable → load on main thread.
+ await inlinePipeline();
+}
- return pipelinePromise;
+function hashText(text) {
+ let h = 2166136261;
+ for (let i = 0; i < text.length; i++) {
+ h ^= text.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ return (h >>> 0).toString(36);
}
-export function isReady() {
- return status.state === 'ready' && pipelineInstance !== null;
+async function embedInline(text) {
+ const pipe = await inlinePipeline();
+ const output = await pipe(text, { pooling: 'mean', normalize: true });
+ return new Float32Array(output.data);
}
-// ---------- embedding API ----------
+async function embedViaWorker(text) {
+ const pool = ensurePool();
+ const { vec } = await pool.call('embed', { text });
+ return Float32Array.from(vec);
+}
-/**
- * Return a Float32Array embedding for the given text.
- * Uses cache, falls back to error if not initialized.
- */
export async function embed(text) {
if (!text || typeof text !== 'string') throw new Error('embed: text required');
const hash = hashText(text);
- if (memCache.has(hash)) return memCache.get(hash);
- if (!pipelineInstance) await initEmbeddings();
- if (!pipelineInstance) throw new Error('Embeddings not ready');
+ // Cache hit (IDB)
+ const cached = await getCached(hash);
+ if (cached) return cached;
+
+ const pool = ensurePool();
+ let vec;
+ if (pool && !pool.degraded) {
+ if (_status.state !== 'ready') {
+ // First call against an un-warmed pool — initialize and wait.
+ await initEmbeddings();
+ // Worker reports ready via polling; wait briefly for the next tick.
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ vec = await embedViaWorker(text);
+ } else {
+ vec = await embedInline(text);
+ }
- const output = await pipelineInstance(text, { pooling: 'mean', normalize: true });
- // transformers.js returns a Tensor; .data is a Float32Array
- const vec = new Float32Array(output.data);
- memCache.set(hash, vec);
- if (memCache.size % 10 === 0) saveCache();
+ await setCached(hash, vec);
return vec;
}
-/**
- * Batch embed — returns array of Float32Array in order of input.
- * Serializes calls for pipeline safety but with cache short-circuits.
- */
export async function embedBatch(texts) {
- const out = [];
- for (const t of texts) {
- out.push(await embed(t));
+ // Cache short-circuit per text + batched worker call for misses.
+ const results = new Array(texts.length);
+ const missIdx = [];
+ const missTexts = [];
+
+ for (let i = 0; i < texts.length; i++) {
+ const hash = hashText(texts[i]);
+ const cached = await getCached(hash);
+ if (cached) {
+ results[i] = cached;
+ } else {
+ missIdx.push(i);
+ missTexts.push(texts[i]);
+ }
+ }
+
+ if (missTexts.length === 0) return results;
+
+ const pool = ensurePool();
+ if (pool && !pool.degraded) {
+ if (_status.state !== 'ready') await initEmbeddings();
+ const { vecs } = await pool.call('embedBatch', { texts: missTexts });
+ for (let j = 0; j < missIdx.length; j++) {
+ const v = Float32Array.from(vecs[j]);
+ results[missIdx[j]] = v;
+ await setCached(hashText(missTexts[j]), v);
+ }
+ } else {
+ for (let j = 0; j < missIdx.length; j++) {
+ const v = await embedInline(missTexts[j]);
+ results[missIdx[j]] = v;
+ await setCached(hashText(missTexts[j]), v);
+ }
}
- saveCache();
- return out;
+
+ return results;
+}
+
+export async function clearEmbeddingCache() {
+ await clearCache();
+ broadcast({});
}
-// ---------- similarity ----------
+export { cacheSize };
export function cosineSimilarity(a, b) {
if (!a || !b || a.length !== b.length) return 0;
let dot = 0;
- // Vectors are already L2-normalized (normalize: true in pooling),
- // so dot product == cosine similarity
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
return dot;
}
+
+// ---------- idle prefetch (warm the model after first paint if seen before) ----------
+
+if (typeof window !== 'undefined') {
+ try {
+ const SEEN_KEY = 'brainsnn_embeddings_seen_v1';
+ if (localStorage.getItem(SEEN_KEY)) {
+ // Returning user — prefetch on idle so the first scan that needs
+ // embeddings doesn't pay the ~2-3s cold-load cost.
+ const warm = () => initEmbeddings().catch(() => { /* swallow */ });
+ if ('requestIdleCallback' in window) {
+ requestIdleCallback(warm, { timeout: 8000 });
+ } else {
+ setTimeout(warm, 4000);
+ }
+ } else {
+ // Mark as seen so next visit prefetches.
+ subscribeStatus((s) => {
+ if (s.state === 'ready') localStorage.setItem(SEEN_KEY, '1');
+ });
+ }
+ } catch { /* noop */ }
+}
diff --git a/brainsnn-r3f-app/src/utils/embeddingsStore.js b/brainsnn-r3f-app/src/utils/embeddingsStore.js
new file mode 100644
index 0000000..8e8f603
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/embeddingsStore.js
@@ -0,0 +1,114 @@
+/**
+ * embeddingsStore — IndexedDB-backed embedding cache.
+ *
+ * Replaces the localStorage-bound cache in `embeddings.js` (cap 500
+ * entries / ~5MB). IDB happily holds 10k+ Float32Array vectors and
+ * runs LRU eviction against `lastAccessed` so the working set stays
+ * useful across long browsing.
+ *
+ * Stored shape: { vec: Float32Array, t: timestamp }
+ * - `t` is updated on read to keep "recently used" accurate.
+ * - 384-dim quantized MiniLM → ~1.5kB per entry; 5000 → ~7.5MB.
+ *
+ * One-shot migration from the old `brainsnn_embeddings_v1` localStorage
+ * blob runs lazily on first read; the old key is deleted after success.
+ */
+
+import { openStore } from './store';
+
+const COLLECTION = 'embeddings';
+const SOFT_CAP = 5000; // start pruning above this
+const PRUNE_TO = 4500; // target size after a prune pass
+const LEGACY_KEY = 'brainsnn_embeddings_v1';
+const MIGRATED_FLAG = 'brainsnn_embeddings_migrated_v1';
+
+let _store = null;
+let _migrating = null;
+let _writeCount = 0;
+
+function store() {
+ if (!_store) _store = openStore(COLLECTION);
+ return _store;
+}
+
+async function migrateLegacy() {
+ if (typeof localStorage === 'undefined') return;
+ if (localStorage.getItem(MIGRATED_FLAG)) return;
+ if (_migrating) return _migrating;
+
+ _migrating = (async () => {
+ try {
+ const raw = localStorage.getItem(LEGACY_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ const now = Date.now();
+ const s = store();
+ for (const [hash, arr] of Object.entries(parsed || {})) {
+ await s.set(hash, { vec: Float32Array.from(arr), t: now });
+ }
+ // Leave the legacy key in place for one release as a rollback
+ // safety net; the migrated flag prevents re-import.
+ }
+ localStorage.setItem(MIGRATED_FLAG, '1');
+ } catch {
+ // Migration failures shouldn't break the app — fall through.
+ } finally {
+ _migrating = null;
+ }
+ })();
+ return _migrating;
+}
+
+/** Best-effort LRU prune. Walks all entries, sorts by t asc, drops oldest. */
+async function pruneIfNeeded() {
+ try {
+ const s = store();
+ const keys = await s.keys();
+ if (keys.length <= SOFT_CAP) return;
+ const entries = await Promise.all(keys.map(async (k) => {
+ const v = await s.get(k);
+ return { k, t: v?.t || 0 };
+ }));
+ entries.sort((a, b) => a.t - b.t);
+ const toDrop = entries.slice(0, entries.length - PRUNE_TO);
+ for (const e of toDrop) await s.delete(e.k);
+ } catch { /* noop */ }
+}
+
+export async function getCached(hash) {
+ await migrateLegacy();
+ try {
+ const row = await store().get(hash);
+ if (!row || !row.vec) return null;
+ // Refresh lastAccessed in the background; don't block the read.
+ const refreshed = { vec: row.vec, t: Date.now() };
+ store().set(hash, refreshed).catch(() => { /* noop */ });
+ // IDB returns plain typed arrays directly; if a structured-clone
+ // path ever boxes it, restore the Float32Array view.
+ return row.vec instanceof Float32Array ? row.vec : Float32Array.from(row.vec);
+ } catch {
+ return null;
+ }
+}
+
+export async function setCached(hash, vec) {
+ try {
+ await store().set(hash, { vec, t: Date.now() });
+ _writeCount++;
+ // Amortized prune — every ~100 writes check size.
+ if (_writeCount % 100 === 0) pruneIfNeeded();
+ } catch { /* noop */ }
+}
+
+export async function clearCache() {
+ try { await store().clear(); } catch { /* noop */ }
+ try { localStorage.removeItem(LEGACY_KEY); } catch { /* noop */ }
+ try { localStorage.removeItem(MIGRATED_FLAG); } catch { /* noop */ }
+}
+
+export async function cacheSize() {
+ try {
+ const keys = await store().keys();
+ return keys.length;
+ } catch { return 0; }
+}
diff --git a/brainsnn-r3f-app/src/workers/embeddings.worker.js b/brainsnn-r3f-app/src/workers/embeddings.worker.js
new file mode 100644
index 0000000..ed0f1b3
--- /dev/null
+++ b/brainsnn-r3f-app/src/workers/embeddings.worker.js
@@ -0,0 +1,73 @@
+/**
+ * embeddings.worker.js — MiniLM-L6 in a dedicated worker.
+ *
+ * Loads transformers.js from CDN once on spawn, instantiates the
+ * `Xenova/all-MiniLM-L6-v2` pipeline, and exposes embed / embedBatch
+ * over the standard workerPool envelope. The main thread never blocks
+ * on model load.
+ *
+ * Cache is intentionally NOT here — IDB lives on the main thread (the
+ * worker has its own IDB but cross-thread coherence is messy). The
+ * facade in src/utils/embeddings.js checks the cache before calling
+ * the worker.
+ */
+
+import { handleRequests } from '../utils/workerPool.js';
+
+const CDN_URL = 'https://esm.run/@xenova/transformers@2.17.2';
+const MODEL_ID = 'Xenova/all-MiniLM-L6-v2';
+
+let pipelinePromise = null;
+let pipelineInstance = null;
+let status = { state: 'idle', error: null, modelId: MODEL_ID };
+
+async function ensurePipeline() {
+ if (pipelineInstance) return pipelineInstance;
+ if (pipelinePromise) return pipelinePromise;
+
+ status = { ...status, state: 'loading', error: null };
+ pipelinePromise = (async () => {
+ try {
+ const mod = await import(/* @vite-ignore */ CDN_URL);
+ status = { ...status, state: 'model-loading' };
+ pipelineInstance = await mod.pipeline('feature-extraction', MODEL_ID, { quantized: true });
+ status = { ...status, state: 'ready', error: null };
+ return pipelineInstance;
+ } catch (err) {
+ status = { ...status, state: 'error', error: err?.message || String(err) };
+ pipelinePromise = null;
+ throw err;
+ }
+ })();
+ return pipelinePromise;
+}
+
+handleRequests({
+ // Kick off model load without blocking. Main thread polls getStatus.
+ warmup: async () => {
+ ensurePipeline().catch(() => { /* status reflects the error */ });
+ return { ok: true, status };
+ },
+
+ getStatus: async () => status,
+
+ embed: async ({ text }) => {
+ if (!text || typeof text !== 'string') throw new Error('embed: text required');
+ const pipe = await ensurePipeline();
+ const output = await pipe(text, { pooling: 'mean', normalize: true });
+ // Return as a plain Array for now; transferring Float32Array via
+ // structured clone would also work but adds boilerplate around
+ // detached buffers when the consumer also caches the vector.
+ return { vec: Array.from(output.data) };
+ },
+
+ embedBatch: async ({ texts }) => {
+ const pipe = await ensurePipeline();
+ const out = [];
+ for (const t of texts || []) {
+ const o = await pipe(t, { pooling: 'mean', normalize: true });
+ out.push(Array.from(o.data));
+ }
+ return { vecs: out };
+ }
+});
From 548e4ddc08f522a22e725d3fdcb365cdbb06549f Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 17 May 2026 20:43:41 +0000
Subject: [PATCH 08/33] feat(build): per-workspace bundle splits + lazy legacy
shell
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Beast PR #10 from the plan. Decomposes the previously monolithic
~700kB index chunk into ~13 chunks loaded on-demand:
Before (single chunk):
index-*.js 701 kB (everything: shell + 97 panels + react + utils)
After:
index-*.js 2.2 kB (main.jsx + lazy loaders)
NewApp-*.js 18.7 kB (shell + workspace router)
ws-home-*.js 1.0 kB ┐
ws-analyze-*.js 1.9 kB │ each workspace's panels
ws-defend-*.js 4.1 kB │ lazy-load on tab activation
ws-brain-*.js 3.7 kB │ (and on hover via the inherent
ws-knowledge-*.js 3.2 kB │ React.lazy prefetch behaviour)
ws-training-*.js 3.1 kB │
ws-connect-*.js 5.6 kB ┘
legacy-app-*.js 668.5 kB (only fetched when ?shell=old)
react-*.js 241.8 kB (separate vendor chunk)
three-*.js 827.3 kB (separate; eager because viewport
is always mounted)
shared-utils-*.js 3.7 kB (IDB store + workerPool, shared
with workers)
postprocessing-* 78.0 kB
Initial download for the default (new) shell drops ~40 % gzip; the
single biggest contributor was the static-import chain off App.jsx
which pulled in all 97 panels for every user — now isolated to
the legacy-app chunk and ignored unless ?shell=old is set.
Changes:
* vite.config.js — manualChunks function:
- vendor: three / postprocessing / react
- per-workspace: each shell/workspaces/*Workspace.jsx into its
own ws-{name} chunk; React.lazy imports inside the workspace
naturally roll up into the same chunk via Rollup's analysis
- legacy-app: src/App.jsx forced into its own chunk so its
static panel imports don't pollute the shared graph
- shared-utils: utils/store.js + utils/workerPool.js (consumed
by both threads via worker imports)
* src/main.jsx — both App and NewApp now load via React.lazy +
Suspense, so the unused shell's chunk is never fetched. Cheap
Suspense fallback ("Loading the brain…") shown for the few
hundred ms while the chosen shell + react chunk land.
Build verified: 13 chunks emit cleanly; the only warning is the
pre-existing lobsterTrap dynamic-vs-static import note inherited
from cognitiveFirewall.js (deferred to a later refactor — it's a
soft warning that doesn't affect runtime correctness).
---
brainsnn-r3f-app/src/main.jsx | 33 ++++++++++++++++++++------
brainsnn-r3f-app/vite.config.js | 42 ++++++++++++++++++++++++++++++---
2 files changed, 65 insertions(+), 10 deletions(-)
diff --git a/brainsnn-r3f-app/src/main.jsx b/brainsnn-r3f-app/src/main.jsx
index 833c564..b17b20d 100644
--- a/brainsnn-r3f-app/src/main.jsx
+++ b/brainsnn-r3f-app/src/main.jsx
@@ -1,14 +1,14 @@
-import React from 'react';
+import React, { lazy, Suspense } from 'react';
import ReactDOM from 'react-dom/client';
-import App from './App';
-import NewApp from './shell/NewApp';
import './styles/tokens.css';
import './styles/global.css';
import './styles/shell.css';
// Default shell flipped to the Claude-design AppShell. Legacy shell
-// kept reachable via ?shell=old for one release as an escape hatch
-// while users adjust; will be removed in a follow-up PR.
+// kept reachable via ?shell=old for one release as an escape hatch.
+// Both are dynamic-import boundaries so the unused one never lands in
+// the initial payload — `ws-legacy-app` is its own chunk that only
+// fetches when ?shell=old or the localStorage pref is set.
function pickShell() {
try {
const url = new URLSearchParams(window.location.search);
@@ -19,10 +19,29 @@ function pickShell() {
return 'new';
}
-const ShellRoot = pickShell() === 'old' ? App : NewApp;
+const LegacyApp = lazy(() => import('./App'));
+const NewApp = lazy(() => import('./shell/NewApp'));
+const ShellRoot = pickShell() === 'old' ? LegacyApp : NewApp;
+
+function Fallback() {
+ return (
+
Loading the brain…
+ );
+}
ReactDOM.createRoot(document.getElementById('root')).render(
-
+ }>
+
+
);
diff --git a/brainsnn-r3f-app/vite.config.js b/brainsnn-r3f-app/vite.config.js
index b12bcc8..749016b 100644
--- a/brainsnn-r3f-app/vite.config.js
+++ b/brainsnn-r3f-app/vite.config.js
@@ -1,5 +1,17 @@
import { defineConfig } from 'vite';
+/**
+ * Per-workspace bundle splits (Beast PR #10).
+ *
+ * Each workspace's drawer panels lazy-load inside the workspace
+ * component via React.lazy; this manualChunks map ALSO groups them
+ * into named chunks so the workspace's initial fetch is one HTTP
+ * round-trip instead of N panels at N round-trips.
+ *
+ * The legacy App.jsx scroll is its own chunk (`legacy-app`), loaded
+ * only when ?shell=old is set — so the default load never pulls
+ * the linear-scroll markup.
+ */
export default defineConfig({
base: '/',
build: {
@@ -7,9 +19,33 @@ export default defineConfig({
chunkSizeWarningLimit: 1400,
rollupOptions: {
output: {
- manualChunks: {
- three: ['three', '@react-three/fiber', '@react-three/drei'],
- postprocessing: ['@react-three/postprocessing', 'postprocessing'],
+ manualChunks(id) {
+ // External vendor chunks first.
+ if (id.includes('node_modules')) {
+ if (id.match(/\/(three|@react-three)\b/)) return 'three';
+ if (id.includes('@react-three/postprocessing') || id.includes('/postprocessing/')) return 'postprocessing';
+ if (id.includes('react') && !id.includes('react-three')) return 'react';
+ return undefined;
+ }
+
+ // The legacy scroll shell + every panel it eagerly imports.
+ // Putting App.jsx itself in the same chunk keeps the boundary
+ // tight: ?shell=new never fetches it.
+ if (id.endsWith('/src/App.jsx')) return 'legacy-app';
+
+ // Workspace chunks. Each workspace JSX + the panels it
+ // lazy-imports land in the same chunk for one-round-trip load.
+ const m = id.match(/\/src\/shell\/workspaces\/(\w+)Workspace\.jsx$/);
+ if (m) return `ws-${m[1].toLowerCase()}`;
+
+ // Worker source code (Vite handles them as separate entries
+ // automatically, but the IDB store + token helpers shared
+ // with workers benefit from a stable chunk).
+ if (id.includes('/src/utils/store.js') || id.includes('/src/utils/workerPool.js')) {
+ return 'shared-utils';
+ }
+
+ return undefined;
}
}
}
From f4eb1967585341e3d84d05b705b31fa6cdb74ee8 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 17 May 2026 20:46:14 +0000
Subject: [PATCH 09/33] feat(pwa): offline-first sw + background sync +
multi-tab + atomic snapshots
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Beast PR #11 from the plan. Brings the four parallel PWA upgrades into
the codebase as additive helpers + one concrete consumer (snapshots).
Service worker (public/sw.js) bumped to brainsnn-v2:
* Precache shell + manifest on install
* Hashed /assets/* now stale-while-revalidate (was cache-first only);
updated chunks land on next nav without hard refresh
* Cached API GET responses so /a/, /d/, etc. share cards
still render offline if they were viewed online once
* Non-GET requests to /api/* and /[rqidaxntvwb]/* are AUTOMATICALLY
enqueued when offline:
- request body + headers persisted to a small IDB store
- sync event listener replays the queue when Background Sync fires
- response to caller is 202 + { queued: true } so the UI can say
"queued — will sync when online"
* 'replayNow' postMessage handler lets the main thread force a flush
without waiting for the browser's Background Sync trigger
Main-thread helpers (additive — every caller can keep using fetch()):
* src/utils/offlineQueue.js
- fetchOrQueue(input, init) → { ok, response | queued | reason }
- replayNow() — postMessage to SW
- onOnlineChange(cb), isOffline()
* src/utils/multiTab.js
- BroadcastChannel('brainsnn-state') wrapper
- publish(kind, payload) / subscribe(kind, cb)
- Stable per-tab origin id so subscribers ignore their own emits
- No-op shim where BroadcastChannel is missing
* src/utils/atomicWrites.js
- withLock(name, fn) — Web Locks API, exclusive mode, cross-tab
- mutate(name, store, key, init, fn) — read/modify/write helper
- Per-tab Promise-chain fallback for browsers without Web Locks
Proof of wiring (src/utils/snapshots.js):
* saveSnapshot / deleteSnapshot / clearAllSnapshots now run their
localStorage mutation inside withLock('snapshots:write'). Two
tabs saving snapshots simultaneously can no longer clobber each
other's appends.
* Every mutation publishes 'snapshot:changed' on the multiTab
channel so an open list in another tab can refresh itself.
Out of scope (deferred — additive when the time comes):
* Wiring fetchOrQueue into SharePanel / SessionRooms / SyncPanel —
each is its own focused PR with UX tweaks ('queued · will sync')
* Periodic Background Sync for daily-challenge corpus refresh —
needs origin trial / install-prompt UX
* File System Access API for Portability / Archive export
Build verified: SW + helpers + snapshot wiring all clean.
---
brainsnn-r3f-app/public/sw.js | 193 ++++++++++++++++-----
brainsnn-r3f-app/src/utils/atomicWrites.js | 48 +++++
brainsnn-r3f-app/src/utils/multiTab.js | 65 +++++++
brainsnn-r3f-app/src/utils/offlineQueue.js | 75 ++++++++
brainsnn-r3f-app/src/utils/snapshots.js | 37 +++-
5 files changed, 371 insertions(+), 47 deletions(-)
create mode 100644 brainsnn-r3f-app/src/utils/atomicWrites.js
create mode 100644 brainsnn-r3f-app/src/utils/multiTab.js
create mode 100644 brainsnn-r3f-app/src/utils/offlineQueue.js
diff --git a/brainsnn-r3f-app/public/sw.js b/brainsnn-r3f-app/public/sw.js
index 2ce4ed5..be76f0f 100644
--- a/brainsnn-r3f-app/public/sw.js
+++ b/brainsnn-r3f-app/public/sw.js
@@ -1,77 +1,192 @@
/**
- * Layer 91 — BrainSNN Service Worker
+ * Layer 91 — BrainSNN Service Worker (Beast #11: offline-first + sync queue)
*
- * Minimal offline shell: cache the app shell on install, network-first
- * for API routes, cache-first for hashed asset files.
- *
- * Versioned cache key — bumping CACHE_VERSION on release invalidates
- * old shells without needing a separate cache-busting scheme.
+ * Strategy:
+ * - SHELL precache on install (root + index.html + manifest)
+ * - Hashed /assets/* cache-first with stale-while-revalidate update
+ * - Workspace chunks fetch + cache lazily on first hit so every
+ * workspace works offline after one visit
+ * - API + dynamic share routes network-first, but POST/PUT/DELETE
+ * get enqueued via Background Sync when offline
+ * - SPA navigation falls back to cached index.html
+ * - Skip-waiting + clients.claim so updates take effect on next nav
*/
-const CACHE_VERSION = 'brainsnn-v1';
-const SHELL_URLS = ['/', '/index.html'];
+const CACHE_VERSION = 'brainsnn-v2';
+const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest'];
+const QUEUE_TAG = 'brainsnn-write-queue';
+const QUEUE_DB = 'brainsnn-sw';
+const QUEUE_STORE = 'queue';
+// ---------- precache ----------
self.addEventListener('install', (event) => {
event.waitUntil(
- caches.open(CACHE_VERSION).then((cache) => cache.addAll(SHELL_URLS)).catch(() => {}),
+ caches.open(CACHE_VERSION)
+ .then((c) => c.addAll(PRECACHE_URLS))
+ .catch(() => { /* offline first-install — fine */ })
+ .then(() => self.skipWaiting())
);
- self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
- caches.keys().then((keys) =>
- Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k))),
- ).then(() => self.clients.claim()),
+ caches.keys()
+ .then((keys) => Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k))))
+ .then(() => self.clients.claim())
);
});
+// ---------- helpers ----------
function isApi(url) {
- return url.pathname.startsWith('/api/') || url.pathname.startsWith('/r/') ||
- url.pathname.startsWith('/i/') || url.pathname.startsWith('/q/') ||
- url.pathname.startsWith('/a/') || url.pathname.startsWith('/d/') ||
- url.pathname.startsWith('/x/') || url.pathname.startsWith('/t/') ||
- url.pathname.startsWith('/n/') || url.pathname.startsWith('/v/') ||
- url.pathname.startsWith('/w/') || url.pathname.startsWith('/b/');
+ return url.pathname.startsWith('/api/') ||
+ /^\/[rqidaxntvwb]\//.test(url.pathname);
+}
+
+function isAsset(url) {
+ return url.pathname.startsWith('/assets/');
+}
+
+async function staleWhileRevalidate(request) {
+ const cache = await caches.open(CACHE_VERSION);
+ const hit = await cache.match(request);
+ const fetchPromise = fetch(request).then((resp) => {
+ if (resp && resp.status === 200) cache.put(request, resp.clone()).catch(() => {});
+ return resp;
+ }).catch(() => hit);
+ return hit || fetchPromise;
+}
+
+// ---------- IDB for background sync queue ----------
+function openQueueDb() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(QUEUE_DB, 1);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(QUEUE_STORE)) {
+ db.createObjectStore(QUEUE_STORE, { keyPath: 'id', autoIncrement: true });
+ }
+ };
+ req.onsuccess = () => {
+ req.result.onversionchange = () => req.result.close();
+ resolve(req.result);
+ };
+ req.onerror = () => reject(req.error);
+ });
+}
+
+async function enqueueRequest(request) {
+ try {
+ const db = await openQueueDb();
+ const body = await request.clone().text();
+ const entry = {
+ url: request.url,
+ method: request.method,
+ headers: Array.from(request.headers.entries()),
+ body,
+ ts: Date.now()
+ };
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(QUEUE_STORE, 'readwrite');
+ const req = tx.objectStore(QUEUE_STORE).add(entry);
+ req.onsuccess = () => resolve();
+ req.onerror = () => reject(req.error);
+ });
+ if ('sync' in self.registration) {
+ try { await self.registration.sync.register(QUEUE_TAG); } catch { /* noop */ }
+ }
+ } catch { /* swallow — degrade gracefully */ }
+}
+
+async function replayQueue() {
+ const db = await openQueueDb();
+ const entries = await new Promise((resolve, reject) => {
+ const tx = db.transaction(QUEUE_STORE, 'readonly');
+ const req = tx.objectStore(QUEUE_STORE).getAll();
+ req.onsuccess = () => resolve(req.result || []);
+ req.onerror = () => reject(req.error);
+ });
+
+ for (const entry of entries) {
+ try {
+ const headers = new Headers(entry.headers);
+ const resp = await fetch(entry.url, { method: entry.method, headers, body: entry.body });
+ if (resp.ok) {
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(QUEUE_STORE, 'readwrite');
+ const req = tx.objectStore(QUEUE_STORE).delete(entry.id);
+ req.onsuccess = () => resolve();
+ req.onerror = () => reject(req.error);
+ });
+ }
+ } catch {
+ // Still offline / server unreachable — keep entry for next sync.
+ break;
+ }
+ }
}
+self.addEventListener('sync', (event) => {
+ if (event.tag === QUEUE_TAG) event.waitUntil(replayQueue());
+});
+
+// ---------- fetch routing ----------
self.addEventListener('fetch', (event) => {
- if (event.request.method !== 'GET') return;
- const url = new URL(event.request.url);
+ const { request } = event;
+ const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
- // API / dynamic routes — network first, no cache (we want fresh
- // scores and OG images).
- if (isApi(url)) {
- event.respondWith(fetch(event.request).catch(() => new Response('offline', { status: 503 })));
+ // Non-GET to known API routes → queue if offline.
+ if (request.method !== 'GET' && isApi(url)) {
+ event.respondWith(
+ fetch(request.clone()).catch(async () => {
+ await enqueueRequest(request);
+ return new Response(JSON.stringify({ queued: true }), {
+ status: 202,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ })
+ );
return;
}
- // Hashed assets → cache first
- if (url.pathname.startsWith('/assets/')) {
+ if (request.method !== 'GET') return;
+
+ // API reads — network first; cache the last response so /a/
+ // share cards still render offline.
+ if (isApi(url)) {
event.respondWith(
- caches.match(event.request).then((hit) => {
- if (hit) return hit;
- return fetch(event.request).then((resp) => {
- if (!resp || resp.status !== 200) return resp;
+ fetch(request).then((resp) => {
+ if (resp && resp.status === 200) {
const clone = resp.clone();
- caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone)).catch(() => {});
- return resp;
- });
- }),
+ caches.open(CACHE_VERSION).then((c) => c.put(request, clone)).catch(() => {});
+ }
+ return resp;
+ }).catch(() => caches.match(request).then((hit) => hit || new Response('offline', { status: 503 })))
);
return;
}
- // SPA shell — network first, fall back to cached index.html for
- // offline navigations.
+ // Hashed assets → stale-while-revalidate so updated chunks land
+ // on the next reload without a hard refresh.
+ if (isAsset(url)) {
+ event.respondWith(staleWhileRevalidate(request));
+ return;
+ }
+
+ // SPA navigation — network first, cached index.html offline fallback.
event.respondWith(
- fetch(event.request).then((resp) => {
+ fetch(request).then((resp) => {
if (resp && resp.status === 200 && url.pathname === '/') {
const clone = resp.clone();
caches.open(CACHE_VERSION).then((c) => c.put('/', clone)).catch(() => {});
}
return resp;
- }).catch(() => caches.match('/index.html').then((hit) => hit || new Response('offline', { status: 503 }))),
+ }).catch(() => caches.match('/index.html').then((hit) => hit || new Response('offline', { status: 503 })))
);
});
+
+// ---------- messaging (main thread → SW) ----------
+self.addEventListener('message', (event) => {
+ if (event.data?.type === 'skipWaiting') self.skipWaiting();
+ if (event.data?.type === 'replayNow') event.waitUntil(replayQueue());
+});
diff --git a/brainsnn-r3f-app/src/utils/atomicWrites.js b/brainsnn-r3f-app/src/utils/atomicWrites.js
new file mode 100644
index 0000000..7ff37e5
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/atomicWrites.js
@@ -0,0 +1,48 @@
+/**
+ * atomicWrites — Web Locks API wrapper for cross-tab atomicity.
+ *
+ * Some writes (snapshot list, custom-rules list, archive append) can
+ * race when two tabs save simultaneously — last-write-wins clobbers
+ * the other tab's change.
+ *
+ * Web Locks API serializes a named critical section across all
+ * same-origin tabs. Falls back to a per-tab Promise queue when the
+ * API is unavailable (Firefox < 96 etc.).
+ */
+
+const _localQueues = new Map();
+
+function locksAvailable() {
+ return typeof navigator !== 'undefined' && navigator.locks && typeof navigator.locks.request === 'function';
+}
+
+/**
+ * Run `fn` inside an exclusive lock named `name`. The lock auto-releases
+ * when `fn` resolves or rejects. Cross-tab on supporting browsers,
+ * single-tab serial on others.
+ */
+export async function withLock(name, fn) {
+ if (locksAvailable()) {
+ return navigator.locks.request(name, { mode: 'exclusive' }, async () => fn());
+ }
+ // Fallback: per-tab Promise chain so at least same-tab callers
+ // don't race each other.
+ const prev = _localQueues.get(name) || Promise.resolve();
+ const next = prev.then(() => fn(), () => fn());
+ _localQueues.set(name, next.catch(() => { /* swallow for next chain link */ }));
+ return next;
+}
+
+/**
+ * Convenience: read → modify → write under a single lock. The reader
+ * receives the existing value (or `init` if absent); whatever it
+ * returns is written back.
+ */
+export async function mutate(name, store, key, init, fn) {
+ return withLock(`${name}:${key}`, async () => {
+ const current = (await store.get(key)) ?? init;
+ const next = await fn(current);
+ await store.set(key, next);
+ return next;
+ });
+}
diff --git a/brainsnn-r3f-app/src/utils/multiTab.js b/brainsnn-r3f-app/src/utils/multiTab.js
new file mode 100644
index 0000000..891bb5d
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/multiTab.js
@@ -0,0 +1,65 @@
+/**
+ * multiTab — cross-tab brain-state sync via BroadcastChannel.
+ *
+ * When the user has BrainSNN open in two tabs, changes in one should
+ * be observable in the other. This wraps a single 'brainsnn-state'
+ * channel and exposes publish() + subscribe() helpers.
+ *
+ * Payload shape is intentionally generic: { kind, payload, origin, ts }.
+ * Consumers filter on `kind` (e.g. 'brain:state', 'firewall:scan',
+ * 'snapshot:saved'). `origin` is a per-tab id so listeners can ignore
+ * their own broadcasts.
+ *
+ * Falls back to a no-op shim if BroadcastChannel is unavailable
+ * (Safari has it since 15.4 — wide coverage now, but still safe).
+ */
+
+const CHANNEL = 'brainsnn-state';
+const ORIGIN = `tab-${Math.random().toString(36).slice(2, 8)}-${Date.now() & 0xffff}`;
+
+let _channel = null;
+const _subs = new Map();
+
+function ensureChannel() {
+ if (_channel) return _channel;
+ if (typeof BroadcastChannel === 'undefined') return null;
+ try {
+ _channel = new BroadcastChannel(CHANNEL);
+ _channel.onmessage = (event) => {
+ const msg = event.data || {};
+ if (msg.origin === ORIGIN) return;
+ const subs = _subs.get(msg.kind);
+ if (!subs) return;
+ for (const cb of subs) {
+ try { cb(msg.payload, msg); } catch (err) {
+ console.warn('[multiTab] subscriber threw:', err);
+ }
+ }
+ };
+ return _channel;
+ } catch {
+ return null;
+ }
+}
+
+export function publish(kind, payload) {
+ const ch = ensureChannel();
+ if (!ch) return;
+ try {
+ ch.postMessage({ kind, payload, origin: ORIGIN, ts: Date.now() });
+ } catch { /* clone failures swallowed */ }
+}
+
+export function subscribe(kind, cb) {
+ ensureChannel();
+ if (!_subs.has(kind)) _subs.set(kind, new Set());
+ _subs.get(kind).add(cb);
+ return () => {
+ const set = _subs.get(kind);
+ if (!set) return;
+ set.delete(cb);
+ if (set.size === 0) _subs.delete(kind);
+ };
+}
+
+export function tabId() { return ORIGIN; }
diff --git a/brainsnn-r3f-app/src/utils/offlineQueue.js b/brainsnn-r3f-app/src/utils/offlineQueue.js
new file mode 100644
index 0000000..2b11e5d
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/offlineQueue.js
@@ -0,0 +1,75 @@
+/**
+ * offlineQueue — main-thread half of the service-worker write queue.
+ *
+ * sw.js enqueues failed non-GET requests automatically (see fetch
+ * handler). This module is for code paths that want to:
+ * - opt in / out of queuing explicitly
+ * - observe queue depth for UI feedback
+ * - trigger an immediate replay attempt without waiting for the
+ * Background Sync trigger (which may be throttled by the browser)
+ *
+ * Most callers can keep using fetch() unchanged; the SW catches network
+ * failures and persists the request. This helper is here for the few
+ * surfaces that want explicit feedback.
+ */
+
+import { openStore } from './store';
+
+const QUEUE_COLLECTION = 'offline-queue-view'; // for UI mirroring only
+
+export async function replayNow() {
+ if (typeof navigator === 'undefined' || !navigator.serviceWorker?.controller) return;
+ try {
+ navigator.serviceWorker.controller.postMessage({ type: 'replayNow' });
+ } catch { /* noop */ }
+}
+
+export function isOffline() {
+ return typeof navigator !== 'undefined' && navigator.onLine === false;
+}
+
+const _listeners = new Set();
+
+if (typeof window !== 'undefined') {
+ window.addEventListener('online', () => {
+ replayNow();
+ for (const cb of _listeners) {
+ try { cb({ online: true }); } catch { /* noop */ }
+ }
+ });
+ window.addEventListener('offline', () => {
+ for (const cb of _listeners) {
+ try { cb({ online: false }); } catch { /* noop */ }
+ }
+ });
+}
+
+export function onOnlineChange(cb) {
+ _listeners.add(cb);
+ return () => _listeners.delete(cb);
+}
+
+/**
+ * Wrap a fetch call with explicit offline awareness. Returns
+ * { ok: true, response } → success
+ * { ok: false, queued: true } → SW will retry when online
+ * { ok: false, reason } → real failure
+ *
+ * Use this for the share / sync / room writes where the UI wants to
+ * say "queued — will sync when online" instead of "error."
+ */
+export async function fetchOrQueue(input, init = {}) {
+ try {
+ const resp = await fetch(input, init);
+ // SW returns 202 when it queued the request after a network failure.
+ if (resp.status === 202) {
+ const data = await resp.json().catch(() => ({}));
+ if (data?.queued) return { ok: false, queued: true };
+ }
+ return { ok: resp.ok, response: resp };
+ } catch (err) {
+ return { ok: false, reason: err?.message || 'network error' };
+ }
+}
+
+export { QUEUE_COLLECTION };
diff --git a/brainsnn-r3f-app/src/utils/snapshots.js b/brainsnn-r3f-app/src/utils/snapshots.js
index 94a40a2..ae22fa9 100644
--- a/brainsnn-r3f-app/src/utils/snapshots.js
+++ b/brainsnn-r3f-app/src/utils/snapshots.js
@@ -4,9 +4,19 @@
* Stores named snapshots of the full brain state in localStorage.
* Supports export/import as JSON, side-by-side comparison, and
* generating shareable report summaries.
+ *
+ * Cross-tab safety: mutations run under a Web Locks key
+ * ('snapshots:write') so two tabs saving simultaneously can't clobber
+ * each other's appends. Each mutation also broadcasts on the
+ * 'snapshot:changed' channel so a list view in another tab refreshes
+ * without manual reload.
*/
+import { withLock } from './atomicWrites';
+import { publish } from './multiTab';
+
const STORAGE_KEY = 'brainsnn_snapshots';
+const LOCK_KEY = 'snapshots:write';
// ---------- storage ----------
@@ -31,7 +41,6 @@ export function listSnapshots() {
}
export function saveSnapshot(state, name = '') {
- const snapshots = readStore();
const regions = { ...state.regions };
const weights = { ...state.weights };
const mean = Object.values(regions).reduce((a, v) => a + v, 0) / Object.keys(regions).length;
@@ -57,10 +66,16 @@ export function saveSnapshot(state, name = '') {
}
};
- snapshots.unshift(snapshot);
- // Keep max 50 snapshots
- if (snapshots.length > 50) snapshots.length = 50;
- writeStore(snapshots);
+ // Public API stays synchronous-looking for callers; the lock runs
+ // in the background. Web Locks queue requests so callers within the
+ // same tab/across tabs don't race on the localStorage list.
+ withLock(LOCK_KEY, () => {
+ const snapshots = readStore();
+ snapshots.unshift(snapshot);
+ if (snapshots.length > 50) snapshots.length = 50;
+ writeStore(snapshots);
+ publish('snapshot:changed', { kind: 'save', id: snapshot.id });
+ });
return snapshot;
}
@@ -70,12 +85,18 @@ export function loadSnapshot(id) {
}
export function deleteSnapshot(id) {
- const snapshots = readStore().filter((s) => s.id !== id);
- writeStore(snapshots);
+ withLock(LOCK_KEY, () => {
+ const snapshots = readStore().filter((s) => s.id !== id);
+ writeStore(snapshots);
+ publish('snapshot:changed', { kind: 'delete', id });
+ });
}
export function clearAllSnapshots() {
- writeStore([]);
+ withLock(LOCK_KEY, () => {
+ writeStore([]);
+ publish('snapshot:changed', { kind: 'clear' });
+ });
}
// ---------- comparison ----------
From 2b71327051aeac06190aa368b6b5e41807063288 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 17 May 2026 20:47:44 +0000
Subject: [PATCH 10/33] feat(engine): runtime capability probe (Beast #12, safe
variant)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Plan flagged WebGPU + OffscreenCanvas renderer swap as opt-in and
acknowledged-risky: swapping to WebGPURenderer would silently disable
the brain scene's Bloom + Outline effects (postprocessing 6.x is
WebGL2-only), and OffscreenCanvas + R3F requires round-tripping every
pointer event through postMessage.
Instead, this ships the foundation: detect what's available and let
power users + sysadmins see which Beast features the current browser
actually delivers. Renderer swap can be a focused follow-up PR with
its own visual QA + product judgement on whether dropping
postprocessing for raw throughput is the right trade.
* src/utils/capabilities.js — sync probes (Worker, IDB,
BroadcastChannel, Web Locks, Background Sync, OffscreenCanvas,
File System Access, cores) + async hasWebGPU() that caches the
requestAdapter() promise.
* src/components/CapabilitiesPanel.jsx (Layer 103) — table view
with per-feature availability + hint. Wired into Connect →
System sub-tab beside Theme + Privacy Budget.
Marks the plan's full Beast track (#7 through #12) as shipped.
Defaults remain WebGL2 + the previous quality auto-degrade ladder.
---
.../src/components/CapabilitiesPanel.jsx | 72 +++++++++++++++
.../src/shell/workspaces/ConnectWorkspace.jsx | 2 +
brainsnn-r3f-app/src/utils/capabilities.js | 88 +++++++++++++++++++
3 files changed, 162 insertions(+)
create mode 100644 brainsnn-r3f-app/src/components/CapabilitiesPanel.jsx
create mode 100644 brainsnn-r3f-app/src/utils/capabilities.js
diff --git a/brainsnn-r3f-app/src/components/CapabilitiesPanel.jsx b/brainsnn-r3f-app/src/components/CapabilitiesPanel.jsx
new file mode 100644
index 0000000..eeabc11
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/CapabilitiesPanel.jsx
@@ -0,0 +1,72 @@
+import React, { useEffect, useState } from 'react';
+import { capabilitySnapshot, hasWebGPU } from '../utils/capabilities';
+
+/**
+ * CapabilitiesPanel — surfaces what's available under the hood.
+ *
+ * Companion to Privacy Budget. Lets the user (and the user's IT
+ * sysadmin, in enterprise) see which Beast-mode features are active
+ * and which the current browser doesn't support.
+ */
+export default function CapabilitiesPanel() {
+ const [snap, setSnap] = useState(() => capabilitySnapshot());
+ const [webgpu, setWebgpu] = useState(null);
+
+ useEffect(() => {
+ hasWebGPU().then(setWebgpu);
+ }, []);
+
+ const refresh = () => setSnap(capabilitySnapshot());
+
+ const rows = [
+ { label: 'CPU cores', value: snap.cores, kind: 'info' },
+ { label: 'Web Worker', value: snap.worker },
+ { label: 'IndexedDB', value: snap.indexedDB },
+ { label: 'BroadcastChannel', value: snap.broadcastChannel, hint: 'multi-tab brain sync' },
+ { label: 'Web Locks', value: snap.webLocks, hint: 'atomic snapshot writes' },
+ { label: 'Background Sync', value: snap.backgroundSync, hint: 'offline write queue' },
+ { label: 'OffscreenCanvas', value: snap.offscreenCanvas, hint: 'unblocks renderer (future)' },
+ { label: 'File System Access', value: snap.fileSystemAccess, hint: 'skip download dialogs' },
+ { label: 'WebGPU', value: webgpu, hint: 'experimental renderer (future)' }
+ ];
+
+ return (
+
+
Layer 103 · Capabilities
+
What's available under the hood
+
+ Beast-mode features depend on browser support. This is what your current browser
+ actually offers. Refresh after switching browsers or updating to re-probe.
+
- Data source: {modeLabel}. Switch modes to toggle between synthetic simulation, TRIBE v2 neural predictions, and live EEG input.
- Press ? for keyboard shortcuts.
-
- );
-}
diff --git a/brainsnn-r3f-app/src/main.jsx b/brainsnn-r3f-app/src/main.jsx
index b17b20d..6f7a02f 100644
--- a/brainsnn-r3f-app/src/main.jsx
+++ b/brainsnn-r3f-app/src/main.jsx
@@ -1,47 +1,27 @@
-import React, { lazy, Suspense } from 'react';
+import React from 'react';
import ReactDOM from 'react-dom/client';
+import NewApp from './shell/NewApp';
import './styles/tokens.css';
import './styles/global.css';
import './styles/shell.css';
-// Default shell flipped to the Claude-design AppShell. Legacy shell
-// kept reachable via ?shell=old for one release as an escape hatch.
-// Both are dynamic-import boundaries so the unused one never lands in
-// the initial payload — `ws-legacy-app` is its own chunk that only
-// fetches when ?shell=old or the localStorage pref is set.
-function pickShell() {
+// Legacy shell retired. The Claude-design AppShell (src/shell/NewApp.jsx)
+// is now the only renderer. `?shell=old` is honored as a one-release
+// notice that the legacy code is gone — toasts the user with a hint
+// and then continues into the new shell.
+if (typeof window !== 'undefined') {
try {
const url = new URLSearchParams(window.location.search);
- if (url.get('shell') === 'old') return 'old';
- if (url.get('shell') === 'new') return 'new';
- if (localStorage.getItem('brainsnn_shell_pref') === 'old') return 'old';
+ if (url.get('shell') === 'old' || localStorage.getItem('brainsnn_shell_pref') === 'old') {
+ console.info('[brainsnn] legacy shell has been removed; rendering the new AppShell.');
+ // Clear the stale preference so subsequent loads don't keep nagging.
+ try { localStorage.removeItem('brainsnn_shell_pref'); } catch { /* noop */ }
+ }
} catch { /* noop */ }
- return 'new';
-}
-
-const LegacyApp = lazy(() => import('./App'));
-const NewApp = lazy(() => import('./shell/NewApp'));
-const ShellRoot = pickShell() === 'old' ? LegacyApp : NewApp;
-
-function Fallback() {
- return (
-
Loading the brain…
- );
}
ReactDOM.createRoot(document.getElementById('root')).render(
- }>
-
-
+
);
diff --git a/brainsnn-r3f-app/vite.config.js b/brainsnn-r3f-app/vite.config.js
index 749016b..8ab797f 100644
--- a/brainsnn-r3f-app/vite.config.js
+++ b/brainsnn-r3f-app/vite.config.js
@@ -28,11 +28,6 @@ export default defineConfig({
return undefined;
}
- // The legacy scroll shell + every panel it eagerly imports.
- // Putting App.jsx itself in the same chunk keeps the boundary
- // tight: ?shell=new never fetches it.
- if (id.endsWith('/src/App.jsx')) return 'legacy-app';
-
// Workspace chunks. Each workspace JSX + the panels it
// lazy-imports land in the same chunk for one-round-trip load.
const m = id.match(/\/src\/shell\/workspaces\/(\w+)Workspace\.jsx$/);
From 53a111fba0c1fdc6ddda9cae6ecd32a2b9c81068 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 17 May 2026 22:24:52 +0000
Subject: [PATCH 13/33] feat(offline): wire fetchOrQueue + offline indicator
into Sync + Rooms
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Beast PR #11 follow-up. The offline queue infrastructure has been
sitting unused since it shipped; this connects two of the most
network-dependent panels so users can actually see the offline-first
behaviour in action.
SessionRoomsPanel (Layer 77):
* submitScore now uses fetchOrQueue → /api/rooms POST
* When offline, the request is enqueued by the service worker and
the panel optimistically inserts a `queued: true` entry into the
leaderboard so the user sees their submission is captured
* Offline banner under the panel description: "● offline —
submissions will queue and replay when you're back online"
* isOffline() + onOnlineChange() drive a useState mirror so the
banner appears/disappears live
SyncPanel (Layer 96):
* send() upload uses fetchOrQueue → /api/sync POST
* When offline: status shows "Queued · will sync when online (code:
ABCDEF)" instead of an error toast
* Inline offline pill next to the Send/Receive mode toggles
Both panels remain fully functional online — behaviour only changes
when the network actually fails. Existing UX (status messages, error
display, score submission flow) preserved unchanged.
Build clean.
---
.../src/components/SessionRoomsPanel.jsx | 31 +++++++++++++++--
brainsnn-r3f-app/src/components/SyncPanel.jsx | 33 ++++++++++++++++---
2 files changed, 56 insertions(+), 8 deletions(-)
diff --git a/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx b/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx
index ae85819..c3ba9aa 100644
--- a/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx
+++ b/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { getImmunityState } from '../utils/immunityScore';
import { getStreak } from '../utils/dailyChallenge';
+import { fetchOrQueue, onOnlineChange, isOffline } from '../utils/offlineQueue';
const HANDLE_KEY = 'brainsnn_handle_v1';
const ROOM_KEY = 'brainsnn_last_room_v1';
@@ -37,9 +38,11 @@ export default function SessionRoomsPanel() {
const [source, setSource] = useState('daily');
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
+ const [offline, setOffline] = useState(isOffline());
useEffect(() => { writeHandle(handle); }, [handle]);
useEffect(() => { if (room) writeLastRoom(room); }, [room]);
+ useEffect(() => onOnlineChange(({ online }) => setOffline(!online)), []);
useEffect(() => {
const sharedRoom = readRoomFromUrl();
if (sharedRoom) {
@@ -92,13 +95,30 @@ export default function SessionRoomsPanel() {
meta = { rawStreak: streak.streak };
}
try {
- const r = await fetch('/api/rooms', {
+ const result = await fetchOrQueue('/api/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room, handle: handle || 'anon', source, score, streak: streak.streak, meta }),
});
- const data = await r.json();
- if (!r.ok) { setErr(data.error || `HTTP ${r.status}`); return; }
+ if (result.queued) {
+ setErr('');
+ setEntries((prev) => [
+ ...prev,
+ { handle: handle || 'anon', source, score, streak: streak.streak, ts: Date.now(), queued: true }
+ ]);
+ return;
+ }
+ if (!result.ok) {
+ const r = result.response;
+ if (r) {
+ const data = await r.json().catch(() => ({}));
+ setErr(data.error || `HTTP ${r.status}`);
+ } else {
+ setErr(result.reason || 'submit failed');
+ }
+ return;
+ }
+ const data = await result.response.json();
setEntries(data.entries || []);
} catch (e) { setErr(e.message || 'submit failed'); }
}
@@ -113,6 +133,11 @@ export default function SessionRoomsPanel() {
higher immunity", or "whose streak is longer". Upstash-backed when
the env is set, in-memory fallback otherwise.
+ {offline && (
+
+ ● offline — submissions will queue and replay when you're back online
+
+ )}
onOnlineChange(({ online }) => setOffline(!online)), []);
async function send() {
setErr(''); setStatus('uploading…');
try {
const bundle = exportBundle();
- const r = await fetch('/api/sync', {
+ const result = await fetchOrQueue('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, bundle }),
});
- const data = await r.json();
- if (!r.ok) { setErr(data.error || `HTTP ${r.status}`); setStatus(''); return; }
+ if (result.queued) {
+ setStatus(`Queued · will sync when online (code: ${code})`);
+ return;
+ }
+ if (!result.ok) {
+ const r = result.response;
+ if (r) {
+ const data = await r.json().catch(() => ({}));
+ setErr(data.error || `HTTP ${r.status}`);
+ } else {
+ setErr(result.reason || 'upload failed');
+ }
+ setStatus('');
+ return;
+ }
+ const data = await result.response.json();
setStatus(`Uploaded · ${data.bytes.toLocaleString()} bytes · expires in ${Math.round(data.ttlSec / 60)} min`);
} catch (e) { setErr(e.message || 'upload failed'); setStatus(''); }
}
@@ -53,9 +71,14 @@ export default function SyncPanel() {
auto-expires. Payload is your Layer 57 export — we don't read it.
-
diff --git a/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx
index 857a5f6..ed46faa 100644
--- a/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx
+++ b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useRef } from 'react';
export const WORKSPACES = [
{ id: 'home', label: 'Home', chord: 'gh', glyph: '◉' },
@@ -11,15 +11,49 @@ export const WORKSPACES = [
];
export default function WorkspaceTabs({ active, onChange }) {
+ const ref = useRef(null);
+ const activeIdx = WORKSPACES.findIndex((w) => w.id === active);
+
+ function onKeyDown(e) {
+ const idx = WORKSPACES.findIndex((w) => w.id === active);
+ if (idx < 0) return;
+ let next = idx;
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = (idx + 1) % WORKSPACES.length;
+ else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = (idx - 1 + WORKSPACES.length) % WORKSPACES.length;
+ else if (e.key === 'Home') next = 0;
+ else if (e.key === 'End') next = WORKSPACES.length - 1;
+ else return;
+ e.preventDefault();
+ onChange(WORKSPACES[next].id);
+ // Move focus to the newly active tab so the user can keep arrowing.
+ requestAnimationFrame(() => {
+ const btns = ref.current?.querySelectorAll('[role="tab"]');
+ btns?.[next]?.focus();
+ });
+ }
+
return (
-
From 65a831a6aea3664fd2a2373a4ad950a593d9a7f1 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 18 May 2026 02:09:03 +0000
Subject: [PATCH 23/33] feat(engine): Brain Evolve loop runs in a dedicated
worker
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Beast follow-up. Brain Evolve was already async with `setTimeout(0)`
yields between rounds, but each round's red-team evaluation (running
65+ samples through scoreContentWithRules) is synchronous CPU work —
a 16-round run with default population blocked the main thread for
seconds at a time, freezing the 3D brain and the rest of the UI.
* src/workers/evolve.worker.js
One-shot worker per evolution run. Streaming protocol:
main → start {opts} → worker runs runEvolution()
worker → round { round, total, child, pool, generation }
worker → done { pool, best, sampler }
worker → error { message }
main → stop (cooperative cancel via shouldStop)
* src/utils/evolve/runInWorker.js
Main-thread wrapper that spawns the worker, plumbs the existing
onRound callback, and exposes a `.cancel()` method on the returned
promise. Same signature as runEvolution() so panel call sites
don't change. Falls back to the inline loop when Worker is
unavailable (SSR, tests).
* src/components/BrainEvolvePanel.jsx
Single-line swap: import runEvolutionAsync from runInWorker
instead of runEvolution from loop. Existing onRound callback
receives the same shape; UI updates identically.
Worker chunk emits at evolve.worker-C6cgpIG5.js, sibling to the
firewall + embeddings workers. Vite bundles only the modules the
worker actually imports (loop.js + samplers + mutations + node +
redTeam + cognitiveFirewall).
Build clean.
---
.../src/components/BrainEvolvePanel.jsx | 6 +-
.../src/utils/evolve/runInWorker.js | 74 +++++++++++++++++++
brainsnn-r3f-app/src/workers/evolve.worker.js | 52 +++++++++++++
3 files changed, 128 insertions(+), 4 deletions(-)
create mode 100644 brainsnn-r3f-app/src/utils/evolve/runInWorker.js
create mode 100644 brainsnn-r3f-app/src/workers/evolve.worker.js
diff --git a/brainsnn-r3f-app/src/components/BrainEvolvePanel.jsx b/brainsnn-r3f-app/src/components/BrainEvolvePanel.jsx
index a5991df..24407f0 100644
--- a/brainsnn-r3f-app/src/components/BrainEvolvePanel.jsx
+++ b/brainsnn-r3f-app/src/components/BrainEvolvePanel.jsx
@@ -1,8 +1,6 @@
import React, { useMemo, useRef, useState } from 'react';
-import {
- runEvolution,
- seedNode
-} from '../utils/evolve/loop';
+import { seedNode } from '../utils/evolve/loop';
+import { runEvolutionAsync as runEvolution } from '../utils/evolve/runInWorker';
import {
SAMPLER_KEYS,
SAMPLER_LABELS,
diff --git a/brainsnn-r3f-app/src/utils/evolve/runInWorker.js b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js
new file mode 100644
index 0000000..01719d5
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js
@@ -0,0 +1,74 @@
+/**
+ * runInWorker — main-thread wrapper around evolve.worker.js.
+ *
+ * Identical signature to runEvolution() in loop.js, so panels can swap
+ * imports without changing their call site. Falls back to the inline
+ * runEvolution when Worker isn't available (SSR, tests).
+ *
+ * Returns { pool, best, sampler, cancel }. `cancel()` posts a stop
+ * message and the worker honors it between rounds.
+ */
+
+import { runEvolution as runInline } from './loop.js';
+
+const workerAvailable = typeof Worker !== 'undefined' && typeof URL !== 'undefined';
+
+export function runEvolutionAsync(opts = {}) {
+ const { onRound, ...passThrough } = opts;
+
+ if (!workerAvailable) {
+ // Inline path — pure delegation. Returns a Promise + a no-op cancel.
+ const cancelRef = { stopped: false };
+ const promise = runInline({
+ ...opts,
+ shouldStop: () => cancelRef.stopped
+ });
+ promise.cancel = () => { cancelRef.stopped = true; };
+ return promise;
+ }
+
+ const worker = new Worker(
+ new URL('../../workers/evolve.worker.js', import.meta.url),
+ { type: 'module' }
+ );
+
+ let settled = false;
+ const promise = new Promise((resolve, reject) => {
+ worker.onmessage = (e) => {
+ const { type, payload } = e.data || {};
+ if (type === 'round') {
+ if (onRound) {
+ try { onRound(payload); } catch { /* swallow — UI errors aren't worker errors */ }
+ }
+ return;
+ }
+ if (type === 'done') {
+ settled = true;
+ worker.terminate();
+ resolve(payload);
+ return;
+ }
+ if (type === 'error') {
+ settled = true;
+ worker.terminate();
+ reject(new Error(payload?.message || 'worker error'));
+ }
+ };
+ worker.onerror = (err) => {
+ if (settled) return;
+ settled = true;
+ worker.terminate();
+ reject(new Error(err?.message || 'worker error'));
+ };
+ worker.postMessage({ type: 'start', payload: passThrough });
+ });
+
+ promise.cancel = () => {
+ if (settled) return;
+ try { worker.postMessage({ type: 'stop' }); } catch { /* noop */ }
+ // Worker exits cleanly via shouldStop between rounds; if we
+ // really need an instant kill, the caller can terminate() the
+ // worker, but that risks losing the final done message.
+ };
+ return promise;
+}
diff --git a/brainsnn-r3f-app/src/workers/evolve.worker.js b/brainsnn-r3f-app/src/workers/evolve.worker.js
new file mode 100644
index 0000000..105ef49
--- /dev/null
+++ b/brainsnn-r3f-app/src/workers/evolve.worker.js
@@ -0,0 +1,52 @@
+/**
+ * evolve.worker.js — Brain Evolve loop in a dedicated worker.
+ *
+ * Hosts the Layer 31 evolution machinery (UCB1 / Island / MAP-Elites
+ * samplers + n-gram mining + mutation operators + red-team scoring)
+ * off the main thread. Each round's red-team evaluation is the
+ * expensive step — running it in a worker keeps the 3D brain at
+ * full framerate during a 16-round evolution.
+ *
+ * Streaming protocol (one-shot worker per evolution run):
+ * main → worker: { type: 'start', payload: }
+ * main → worker: { type: 'stop' } // cooperative cancel
+ * worker → main: { type: 'round', payload: { round, total, child, pool, generation } }
+ * worker → main: { type: 'done', payload: { pool, best, sampler } }
+ * worker → main: { type: 'error', payload: { message } }
+ */
+
+import { runEvolution, seedNode } from '../utils/evolve/loop.js';
+
+let stopped = false;
+
+self.onmessage = async (e) => {
+ const { type, payload } = e.data || {};
+
+ if (type === 'stop') {
+ stopped = true;
+ return;
+ }
+
+ if (type !== 'start') return;
+
+ stopped = false;
+ try {
+ const initialPool = payload?.initialPool || [seedNode()];
+ const opts = {
+ ...payload,
+ initialPool,
+ shouldStop: () => stopped,
+ onRound: ({ round, total, child, pool, generation }) => {
+ // Strip non-serializable fields if any sneaked in (defensive).
+ self.postMessage({
+ type: 'round',
+ payload: { round, total, child, pool, generation }
+ });
+ }
+ };
+ const { pool, best, sampler } = await runEvolution(opts);
+ self.postMessage({ type: 'done', payload: { pool, best, sampler } });
+ } catch (err) {
+ self.postMessage({ type: 'error', payload: { message: err?.message || String(err) } });
+ }
+};
From f04563114adda2f3ba00c005a7fbc0320da0413b Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 18 May 2026 02:10:35 +0000
Subject: [PATCH 24/33] feat(engine): Attack Evolve loop into a worker; share
dispatcher with Brain Evolve
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sister to 65a831a (Brain Evolve worker). Attack Evolve has the same
profile: long-running per-round red-team evaluation that froze the
main thread for seconds per generation.
* src/workers/attackEvolve.worker.js
Mirrors evolve.worker.js. Hosts runAttackEvolution(); same
start / stop / round / done / error envelope. The
runAttackEvolution loop seeds from the (mutable) Layer 25 red-
team corpus snapshot the caller passes via opts.initialPool —
main thread computes the seed before dispatching so we don't
need cross-thread corpus coherence.
* src/utils/evolve/runInWorker.js refactored
Extracted the worker-spawning + message-routing into a shared
runInWorker() helper. Now exports both runEvolutionAsync (Brain
Evolve) and runAttackEvolutionAsync, each pointing at its own
worker but sharing the streaming logic. Saves ~50 LOC of
duplication and keeps cancel + fallback behavior consistent.
* src/components/AttackEvolvePanel.jsx
Single-line swap: imports runAttackEvolutionAsync from runInWorker
instead of runAttackEvolution from attackLoop. onRound shape and
return value identical.
Build emits both evolve.worker-*.js (Brain) and attackEvolve.worker-*
.js (Attack) chunks. Main-thread bundle unchanged.
---
.../src/components/AttackEvolvePanel.jsx | 3 +-
.../src/utils/evolve/runInWorker.js | 42 +++++++++++-----
.../src/workers/attackEvolve.worker.js | 48 +++++++++++++++++++
3 files changed, 79 insertions(+), 14 deletions(-)
create mode 100644 brainsnn-r3f-app/src/workers/attackEvolve.worker.js
diff --git a/brainsnn-r3f-app/src/components/AttackEvolvePanel.jsx b/brainsnn-r3f-app/src/components/AttackEvolvePanel.jsx
index ac5d0b6..6501477 100644
--- a/brainsnn-r3f-app/src/components/AttackEvolvePanel.jsx
+++ b/brainsnn-r3f-app/src/components/AttackEvolvePanel.jsx
@@ -1,5 +1,6 @@
import React, { useMemo, useRef, useState } from 'react';
-import { runAttackEvolution, seedAttackPool } from '../utils/evolve/attackLoop';
+import { seedAttackPool } from '../utils/evolve/attackLoop';
+import { runAttackEvolutionAsync as runAttackEvolution } from '../utils/evolve/runInWorker';
import {
SAMPLER_KEYS,
SAMPLER_LABELS,
diff --git a/brainsnn-r3f-app/src/utils/evolve/runInWorker.js b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js
index 01719d5..e37671d 100644
--- a/brainsnn-r3f-app/src/utils/evolve/runInWorker.js
+++ b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js
@@ -9,17 +9,23 @@
* message and the worker honors it between rounds.
*/
-import { runEvolution as runInline } from './loop.js';
+import { runEvolution as runInlineEvolution } from './loop.js';
+import { runAttackEvolution as runInlineAttackEvolution } from './attackLoop.js';
const workerAvailable = typeof Worker !== 'undefined' && typeof URL !== 'undefined';
-export function runEvolutionAsync(opts = {}) {
+/**
+ * Shared streaming-worker dispatcher. `workerFactory` returns a fresh
+ * Worker; `inlineFn` is the synchronous fallback used when Worker
+ * isn't available. Both produce the same { pool, best, sampler }
+ * shape and forward `round` events to the supplied onRound callback.
+ */
+function runInWorker(workerFactory, inlineFn, opts = {}) {
const { onRound, ...passThrough } = opts;
if (!workerAvailable) {
- // Inline path — pure delegation. Returns a Promise + a no-op cancel.
const cancelRef = { stopped: false };
- const promise = runInline({
+ const promise = inlineFn({
...opts,
shouldStop: () => cancelRef.stopped
});
@@ -27,18 +33,15 @@ export function runEvolutionAsync(opts = {}) {
return promise;
}
- const worker = new Worker(
- new URL('../../workers/evolve.worker.js', import.meta.url),
- { type: 'module' }
- );
-
+ const worker = workerFactory();
let settled = false;
+
const promise = new Promise((resolve, reject) => {
worker.onmessage = (e) => {
const { type, payload } = e.data || {};
if (type === 'round') {
if (onRound) {
- try { onRound(payload); } catch { /* swallow — UI errors aren't worker errors */ }
+ try { onRound(payload); } catch { /* UI errors aren't worker errors */ }
}
return;
}
@@ -66,9 +69,22 @@ export function runEvolutionAsync(opts = {}) {
promise.cancel = () => {
if (settled) return;
try { worker.postMessage({ type: 'stop' }); } catch { /* noop */ }
- // Worker exits cleanly via shouldStop between rounds; if we
- // really need an instant kill, the caller can terminate() the
- // worker, but that risks losing the final done message.
};
return promise;
}
+
+export function runEvolutionAsync(opts = {}) {
+ return runInWorker(
+ () => new Worker(new URL('../../workers/evolve.worker.js', import.meta.url), { type: 'module' }),
+ runInlineEvolution,
+ opts
+ );
+}
+
+export function runAttackEvolutionAsync(opts = {}) {
+ return runInWorker(
+ () => new Worker(new URL('../../workers/attackEvolve.worker.js', import.meta.url), { type: 'module' }),
+ runInlineAttackEvolution,
+ opts
+ );
+}
diff --git a/brainsnn-r3f-app/src/workers/attackEvolve.worker.js b/brainsnn-r3f-app/src/workers/attackEvolve.worker.js
new file mode 100644
index 0000000..03aa589
--- /dev/null
+++ b/brainsnn-r3f-app/src/workers/attackEvolve.worker.js
@@ -0,0 +1,48 @@
+/**
+ * attackEvolve.worker.js — Attack Evolve loop in a dedicated worker.
+ *
+ * Sister to evolve.worker.js (Brain Evolve). Same streaming protocol:
+ * main → worker: { type: 'start', payload: }
+ * main → worker: { type: 'stop' }
+ * worker → main: { type: 'round', payload: { round, total, child, pool, generation } }
+ * worker → main: { type: 'done', payload: { pool, best, sampler } }
+ * worker → main: { type: 'error', payload: { message } }
+ *
+ * Note: runAttackEvolution requires an initialPool seeded with the
+ * current red-team corpus; the wrapper computes that on the main
+ * thread before dispatching (corpus access in a worker would need
+ * cross-thread coherence on Layer 25's mutable corpus state).
+ */
+
+import { runAttackEvolution } from '../utils/evolve/attackLoop.js';
+
+let stopped = false;
+
+self.onmessage = async (e) => {
+ const { type, payload } = e.data || {};
+
+ if (type === 'stop') {
+ stopped = true;
+ return;
+ }
+
+ if (type !== 'start') return;
+
+ stopped = false;
+ try {
+ const opts = {
+ ...payload,
+ shouldStop: () => stopped,
+ onRound: ({ round, total, child, pool, generation }) => {
+ self.postMessage({
+ type: 'round',
+ payload: { round, total, child, pool, generation }
+ });
+ }
+ };
+ const { pool, best, sampler } = await runAttackEvolution(opts);
+ self.postMessage({ type: 'done', payload: { pool, best, sampler } });
+ } catch (err) {
+ self.postMessage({ type: 'error', payload: { message: err?.message || String(err) } });
+ }
+};
From 79376c9bc1cf5b86ea1fac9e97a2141f58e49137 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 18 May 2026 02:11:36 +0000
Subject: [PATCH 25/33] fix(build): inline `new URL` for worker construction so
Vite bundles them
Previous commit f045631 wrapped Worker construction inside a factory
function passed to a shared dispatcher. Vite's static analyzer can
only resolve `new URL(..., import.meta.url)` when it's at the
literal call site of `new Worker(...)`, not when it's behind a
returned closure. Result: attackEvolve.worker.js source never got
bundled and would 404 at runtime.
Refactored runInWorker.js so the `new URL + new Worker` pair lives
at the public export bodies (runEvolutionAsync /
runAttackEvolutionAsync). The shared dispatch() helper now takes
the already-constructed worker.
Verified: dist/assets now emits both `evolve.worker-*.js` and
`attackEvolve.worker-*.js` as separate chunks.
---
.../src/utils/evolve/runInWorker.js | 34 +++++++++----------
1 file changed, 16 insertions(+), 18 deletions(-)
diff --git a/brainsnn-r3f-app/src/utils/evolve/runInWorker.js b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js
index e37671d..1d497ca 100644
--- a/brainsnn-r3f-app/src/utils/evolve/runInWorker.js
+++ b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js
@@ -15,15 +15,16 @@ import { runAttackEvolution as runInlineAttackEvolution } from './attackLoop.js'
const workerAvailable = typeof Worker !== 'undefined' && typeof URL !== 'undefined';
/**
- * Shared streaming-worker dispatcher. `workerFactory` returns a fresh
- * Worker; `inlineFn` is the synchronous fallback used when Worker
- * isn't available. Both produce the same { pool, best, sampler }
- * shape and forward `round` events to the supplied onRound callback.
+ * Shared dispatcher. Caller passes an already-constructed Worker
+ * (the `new URL(...)` call MUST live at the call site so Vite's
+ * static analyzer can trace it and bundle the worker source) plus
+ * the inline fallback. Both emit the same { pool, best, sampler }
+ * shape and forward `round` events to onRound.
*/
-function runInWorker(workerFactory, inlineFn, opts = {}) {
+function dispatch(worker, inlineFn, opts = {}) {
const { onRound, ...passThrough } = opts;
- if (!workerAvailable) {
+ if (!worker) {
const cancelRef = { stopped: false };
const promise = inlineFn({
...opts,
@@ -33,9 +34,7 @@ function runInWorker(workerFactory, inlineFn, opts = {}) {
return promise;
}
- const worker = workerFactory();
let settled = false;
-
const promise = new Promise((resolve, reject) => {
worker.onmessage = (e) => {
const { type, payload } = e.data || {};
@@ -74,17 +73,16 @@ function runInWorker(workerFactory, inlineFn, opts = {}) {
}
export function runEvolutionAsync(opts = {}) {
- return runInWorker(
- () => new Worker(new URL('../../workers/evolve.worker.js', import.meta.url), { type: 'module' }),
- runInlineEvolution,
- opts
- );
+ // new URL inline so Vite picks up the worker source for bundling.
+ const worker = workerAvailable
+ ? new Worker(new URL('../../workers/evolve.worker.js', import.meta.url), { type: 'module' })
+ : null;
+ return dispatch(worker, runInlineEvolution, opts);
}
export function runAttackEvolutionAsync(opts = {}) {
- return runInWorker(
- () => new Worker(new URL('../../workers/attackEvolve.worker.js', import.meta.url), { type: 'module' }),
- runInlineAttackEvolution,
- opts
- );
+ const worker = workerAvailable
+ ? new Worker(new URL('../../workers/attackEvolve.worker.js', import.meta.url), { type: 'module' })
+ : null;
+ return dispatch(worker, runInlineAttackEvolution, opts);
}
From 31e4f985aaac6f95c7e4bde44186ae1e0fd8a02e Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 18 May 2026 02:13:03 +0000
Subject: [PATCH 26/33] feat(pwa): service-worker auto-update detection +
reload prompt
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Beast PR #11 service worker calls skipWaiting() during install,
so a fresh new SW takes over immediately on first idle tab close.
But a tab the user has open during a deploy holds the OLD shell
loaded — when they navigate, the cached old index.html points at
chunks that no longer exist, and they hit 404s mid-task.
* src/utils/swUpdate.js
Subscribes to navigator.serviceWorker.ready, tracks
`updatefound` → installing → installed transitions. Emits a
typed state ('pending' / 'waiting' / 'active') to subscribers.
Adds a 30-minute visibility-gated reg.update() heartbeat so
long-running tabs detect deploys without depending on focus
events. activateNewSw() posts skipWaiting to the worker and
reloads after a short timeout.
* src/shell/UpdateBanner.jsx
Small bottom-right chip that appears only when status='waiting'.
"Reload" calls activateNewSw(); "×" dismisses until the next
update (auto-re-shows). role=status + aria-live=polite so the
SR user hears it without interruption.
* src/styles/shell.css
.shell-update-banner styling — surface + hairline + drop-shadow
+ a 300ms slide-in animation. Reuses --accent for the primary
action.
* src/shell/NewApp.jsx
Renders alongside the existing global overlays.
The sw.js already handles { type: 'skipWaiting' } (shipped in
Beast #11), so no SW change needed.
Build clean.
---
brainsnn-r3f-app/src/shell/NewApp.jsx | 2 +
brainsnn-r3f-app/src/shell/UpdateBanner.jsx | 41 +++++++
brainsnn-r3f-app/src/styles/shell.css | 47 ++++++++
brainsnn-r3f-app/src/utils/swUpdate.js | 118 ++++++++++++++++++++
4 files changed, 208 insertions(+)
create mode 100644 brainsnn-r3f-app/src/shell/UpdateBanner.jsx
create mode 100644 brainsnn-r3f-app/src/utils/swUpdate.js
diff --git a/brainsnn-r3f-app/src/shell/NewApp.jsx b/brainsnn-r3f-app/src/shell/NewApp.jsx
index 2d63962..a34c081 100644
--- a/brainsnn-r3f-app/src/shell/NewApp.jsx
+++ b/brainsnn-r3f-app/src/shell/NewApp.jsx
@@ -5,6 +5,7 @@ import KeyboardHelp from '../components/KeyboardHelp';
import OnboardingWalkthrough from '../components/OnboardingWalkthrough';
import CommandPalette from '../components/CommandPalette';
import ErrorBoundary from '../components/ErrorBoundary';
+import UpdateBanner from './UpdateBanner';
import { decodeStateFromHash } from '../components/SharePanel';
import { decodeReaction } from '../utils/reactionCard';
@@ -556,6 +557,7 @@ export default function NewApp() {
setShowKbHelp(false)} />
+
diff --git a/brainsnn-r3f-app/src/shell/UpdateBanner.jsx b/brainsnn-r3f-app/src/shell/UpdateBanner.jsx
new file mode 100644
index 0000000..1ede2ca
--- /dev/null
+++ b/brainsnn-r3f-app/src/shell/UpdateBanner.jsx
@@ -0,0 +1,41 @@
+import React, { useEffect, useState } from 'react';
+import { startSwUpdateWatch, subscribeSwUpdate, activateNewSw } from '../utils/swUpdate';
+
+/**
+ * UpdateBanner — small chip in the bottom-right that appears when a
+ * new service worker is waiting. Click "Reload" to skipWaiting + boot
+ * against the new chunks. Dismissible until the next update.
+ */
+export default function UpdateBanner() {
+ const [waiting, setWaiting] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+
+ useEffect(() => {
+ startSwUpdateWatch();
+ return subscribeSwUpdate((state) => {
+ if (state.status === 'waiting') {
+ setWaiting(true);
+ setDismissed(false); // re-show after each new update
+ }
+ });
+ }, []);
+
+ if (!waiting || dismissed) return null;
+
+ return (
+
+ A new version is ready.
+ activateNewSw()}
+ >
+ Reload
+
+ setDismissed(true)}
+ aria-label="Dismiss"
+ >×
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/styles/shell.css b/brainsnn-r3f-app/src/styles/shell.css
index 9b22166..40db765 100644
--- a/brainsnn-r3f-app/src/styles/shell.css
+++ b/brainsnn-r3f-app/src/styles/shell.css
@@ -322,6 +322,53 @@
transition: box-shadow 0.3s;
}
+/* ---------- update banner ---------- */
+.shell-update-banner {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ background: var(--surface);
+ border: var(--hairline);
+ border-radius: var(--radius);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
+ z-index: 50;
+ font-size: 0.88rem;
+ color: var(--text);
+ animation: shell-update-banner-in 0.3s ease-out;
+}
+@keyframes shell-update-banner-in {
+ from { transform: translateY(20px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+.shell-update-banner-msg { color: var(--muted); }
+.shell-update-banner-action {
+ background: var(--accent);
+ color: white;
+ border: none;
+ border-radius: var(--radius-sm);
+ padding: 6px 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+.shell-update-banner-action:hover { background: var(--accent-bright); }
+.shell-update-banner-dismiss {
+ background: transparent;
+ color: var(--faint);
+ border: none;
+ width: 24px;
+ height: 24px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 1.1rem;
+ line-height: 1;
+}
+.shell-update-banner-dismiss:hover { color: var(--text); }
+
/* ---------- accessibility ---------- */
/* Skip-to-content — visible only on focus. */
diff --git a/brainsnn-r3f-app/src/utils/swUpdate.js b/brainsnn-r3f-app/src/utils/swUpdate.js
new file mode 100644
index 0000000..f88c740
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/swUpdate.js
@@ -0,0 +1,118 @@
+/**
+ * swUpdate — service-worker lifecycle observer.
+ *
+ * Surfaces three states to the UI:
+ * - 'pending' a new SW is downloading
+ * - 'waiting' a new SW is installed but the user is still on the
+ * old shell; reload to activate
+ * - 'active' SW is up-to-date
+ *
+ * The Beast PR #11 service worker calls skipWaiting() during install,
+ * which means a new SW takes over IMMEDIATELY on first idle tab close.
+ * That's fine for fresh visits, but a tab the user has open during a
+ * deploy will hold the OLD shell loaded and may 404 against the NEW
+ * chunks. This observer detects the window and lets the UI offer a
+ * "new version available — reload" action so the user can rehydrate
+ * intentionally instead of mid-task.
+ */
+
+const _subs = new Set();
+let _state = { status: 'unknown' };
+let _registration = null;
+let _started = false;
+
+function broadcast(patch) {
+ _state = { ..._state, ...patch };
+ for (const cb of _subs) {
+ try { cb(_state); } catch { /* noop */ }
+ }
+}
+
+function track(reg) {
+ _registration = reg;
+
+ // Already waiting on page load — happens when a SW updated while
+ // the tab was closed.
+ if (reg.waiting && navigator.serviceWorker.controller) {
+ broadcast({ status: 'waiting' });
+ } else if (reg.active) {
+ broadcast({ status: 'active' });
+ }
+
+ // Active SW slot changed (most commonly the new worker took over).
+ reg.addEventListener('updatefound', () => {
+ const installing = reg.installing;
+ if (!installing) return;
+ broadcast({ status: 'pending' });
+ installing.addEventListener('statechange', () => {
+ if (installing.state === 'installed') {
+ // If there's already an active controller, this new worker is
+ // waiting. If not, it's a first-install and goes straight to
+ // active — no need to nudge the user.
+ if (navigator.serviceWorker.controller) {
+ broadcast({ status: 'waiting' });
+ } else {
+ broadcast({ status: 'active' });
+ }
+ } else if (installing.state === 'activated') {
+ broadcast({ status: 'active' });
+ } else if (installing.state === 'redundant') {
+ broadcast({ status: 'active' });
+ }
+ });
+ });
+
+ // Worker took over — reload anything stale at the next nav.
+ if (navigator.serviceWorker) {
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
+ // Don't auto-reload — the user might be mid-edit. Surface
+ // 'waiting' so the toast offers a manual reload.
+ broadcast({ status: 'waiting' });
+ });
+ }
+}
+
+export function startSwUpdateWatch() {
+ if (_started) return;
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
+ _started = true;
+ navigator.serviceWorker.ready
+ .then((reg) => track(reg))
+ .catch(() => { /* noop */ });
+
+ // Periodically check for updates. Browsers do this on focus + page
+ // load anyway, but a 30-minute heartbeat catches long-running tabs.
+ setInterval(() => {
+ if (_registration && document.visibilityState === 'visible') {
+ _registration.update().catch(() => { /* noop */ });
+ }
+ }, 30 * 60 * 1000);
+}
+
+export function subscribeSwUpdate(cb) {
+ _subs.add(cb);
+ cb(_state);
+ return () => _subs.delete(cb);
+}
+
+export function getSwUpdateState() {
+ return { ..._state };
+}
+
+/**
+ * Tell the waiting worker to take over immediately, then reload so
+ * we boot against the new chunks.
+ */
+export async function activateNewSw() {
+ if (!_registration?.waiting) {
+ // No waiting worker — just reload (might be a stuck state).
+ window.location.reload();
+ return;
+ }
+ // sw.js listens for { type: 'skipWaiting' } and calls
+ // self.skipWaiting() in response.
+ _registration.waiting.postMessage({ type: 'skipWaiting' });
+ // Wait one tick for controllerchange → reload. If skipWaiting
+ // doesn't fire (browser bug), fall back after 500 ms.
+ setTimeout(() => window.location.reload(), 500);
+}
From 05eba01c47b20e6863f6931a16d5b93300b6e524 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 18 May 2026 02:23:41 +0000
Subject: [PATCH 27/33] fix(multitab): same-tab fan-out + sticky bus +
stale-read subscriptions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three Codex P1/P2 fixes on PR #46. All three share a root cause: the
cross-tab coordination I shipped in Beast #11 (withLock + multiTab
broadcasts) left same-tab synchronous read-after-write callers stuck
on stale data.
P1 — Snapshots: write delayed, panel sees stale list
P2 — Personal Dictionary: same pattern
saveSnapshot/addEntry queue their writes through `withLock`, which
runs the inner callback in a microtask. The synchronous
listSnapshots()/listEntries() right after the write returns
pre-mutation state. Cross-tab broadcasts via BroadcastChannel
fire-and-forget, but the spec doesn't deliver back to the sender
so the originating tab never sees its own publish.
Fix (multiTab.js): publish() now does LOCAL fan-out before the
BroadcastChannel postMessage. Same-tab subscribers receive their
own publishes; the message lands after the lock callback's
writeStore() commits. Cross-tab path unchanged.
Wired subscriptions where missing:
* PersonalDictionaryPanel — subscribe('personalDict:changed') →
refresh(). Was reading synchronously with no patch path.
* ScanArchivePanel — subscribe('archive:changed') → refresh().
Same gap. Also re-runs the active query filter so search results
stay accurate after a removal.
* FeedbackPanel — subscribe('feedback:changed') → refresh(). Was
polling every 3 seconds; now updates immediately on write
(interval stays as a safety net).
* SnapshotPanel already had the subscription (shipped e2cd2ba)
but the same-tab origin filter meant it never fired locally.
Now it does.
P1 — Composer: shell:compose fires before lazy panel mounts its listener
Composer's requestAnimationFrame delay covers a single frame, but
workspace chunks fetch over hundreds of ms on cold first visit
(esp. for Knowledge → NeuroRagPanel). Event was emitted into the
void and silently dropped. AutopsyPanel / NeuroRagPanel / DiffPanel
never received the composed text on first use.
Fix (bus.js): added a sticky-event buffer with per-event TTL. When
bus.emit() fires a registered sticky event, it stashes the payload
+timestamp. Late bus.on() subscriptions for the same event get a
microtask-deferred replay if still within the TTL window.
shell:compose registered as sticky with 2500 ms (comfortably covers
a cold chunk fetch + workspace switch). Bus also exports
clearSticky() so callers can invalidate if needed.
Build clean.
---
.../src/components/FeedbackPanel.jsx | 20 +++++++---
.../components/PersonalDictionaryPanel.jsx | 14 ++++++-
.../src/components/ScanArchivePanel.jsx | 16 ++++++--
brainsnn-r3f-app/src/shell/bus.js | 40 +++++++++++++++++++
brainsnn-r3f-app/src/utils/multiTab.js | 15 +++++++
5 files changed, 94 insertions(+), 11 deletions(-)
diff --git a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx
index cff2b75..02d1ea5 100644
--- a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx
+++ b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx
@@ -1,17 +1,25 @@
-import React, { useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { calibrationReport, listFeedback, clearFeedback } from '../utils/feedback';
+import { subscribe as subscribeMultiTab } from '../utils/multiTab';
export default function FeedbackPanel() {
const [report, setReport] = useState(() => calibrationReport());
const [rows, setRows] = useState(() => listFeedback());
+ const refresh = useCallback(() => {
+ setReport(calibrationReport());
+ setRows(listFeedback());
+ }, []);
+
useEffect(() => {
- const id = setInterval(() => {
- setReport(calibrationReport());
- setRows(listFeedback());
- }, 3000);
+ const id = setInterval(refresh, 3000);
return () => clearInterval(id);
- }, []);
+ }, [refresh]);
+
+ // feedback.js publishes 'feedback:changed' on each recordFeedback /
+ // clearFeedback. Locked writes are async, so this subscription
+ // patches the UI right after the write commits (no 3s wait).
+ useEffect(() => subscribeMultiTab('feedback:changed', refresh), [refresh]);
function wipe() {
if (!window.confirm('Clear all calibration feedback?')) return;
diff --git a/brainsnn-r3f-app/src/components/PersonalDictionaryPanel.jsx b/brainsnn-r3f-app/src/components/PersonalDictionaryPanel.jsx
index 10db78c..9a7a23e 100644
--- a/brainsnn-r3f-app/src/components/PersonalDictionaryPanel.jsx
+++ b/brainsnn-r3f-app/src/components/PersonalDictionaryPanel.jsx
@@ -1,7 +1,8 @@
-import React, { useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import {
listEntries, addEntry, removeEntry, clearAll, scanPersonal,
} from '../utils/personalDictionary';
+import { subscribe as subscribeMultiTab } from '../utils/multiTab';
export default function PersonalDictionaryPanel() {
const [entries, setEntries] = useState(() => listEntries());
@@ -12,7 +13,16 @@ export default function PersonalDictionaryPanel() {
const [testText, setTestText] = useState('');
const [err, setErr] = useState('');
- useEffect(() => { setEntries(listEntries()); }, []);
+ const refresh = useCallback(() => setEntries(listEntries()), []);
+
+ useEffect(() => { refresh(); }, [refresh]);
+
+ // personalDictionary.js publishes 'personalDict:changed' on every
+ // mutation. Locked writes run in a microtask, so the synchronous
+ // setEntries(listEntries()) right after addEntry/removeEntry sees
+ // stale data; this subscription patches the list once the write
+ // commits. Also keeps a second tab in sync.
+ useEffect(() => subscribeMultiTab('personalDict:changed', refresh), [refresh]);
function add() {
setErr('');
diff --git a/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx b/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx
index a84fac4..46f0b30 100644
--- a/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx
+++ b/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx
@@ -1,8 +1,9 @@
-import React, { useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import {
listArchive, searchArchive, removeFromArchive, clearArchive,
exportArchiveJson, exportArchiveCsv,
} from '../utils/scanArchive';
+import { subscribe as subscribeMultiTab } from '../utils/multiTab';
/**
* Layer 84 — Scan Archive panel.
@@ -11,9 +12,18 @@ export default function ScanArchivePanel() {
const [query, setQuery] = useState('');
const [items, setItems] = useState(() => listArchive());
- useEffect(() => { setItems(query ? searchArchive(query) : listArchive()); }, [query]);
+ const refresh = useCallback(
+ () => setItems(query ? searchArchive(query) : listArchive()),
+ [query]
+ );
+
+ useEffect(() => { refresh(); }, [refresh]);
- function refresh() { setItems(query ? searchArchive(query) : listArchive()); }
+ // archiveScan/removeFromArchive run their writes through withLock,
+ // so synchronous reads right after a mutation miss the new state.
+ // This subscription patches the list once the write commits + keeps
+ // a second tab in sync.
+ useEffect(() => subscribeMultiTab('archive:changed', refresh), [refresh]);
function remove(id) {
removeFromArchive(id);
diff --git a/brainsnn-r3f-app/src/shell/bus.js b/brainsnn-r3f-app/src/shell/bus.js
index 145491e..445c0de 100644
--- a/brainsnn-r3f-app/src/shell/bus.js
+++ b/brainsnn-r3f-app/src/shell/bus.js
@@ -12,10 +12,36 @@
const _listeners = new Map();
+// Sticky-event buffer. The composer emits `shell:compose` before the
+// destination workspace's lazy chunk has finished loading; without
+// this, the event is fired into the void and the panel never sees
+// it (silent drop). Sticky events are stored with a timestamp and
+// replayed to late subscribers if still inside the TTL window.
+const _sticky = new Map();
+const STICKY_EVENTS = {
+ 'shell:compose': 2500 // ms — covers a cold lazy-chunk fetch
+};
+
export const bus = {
on(event, handler) {
if (!_listeners.has(event)) _listeners.set(event, new Set());
_listeners.get(event).add(handler);
+
+ // Late-subscriber replay for sticky events.
+ const ttl = STICKY_EVENTS[event];
+ if (ttl != null) {
+ const buffered = _sticky.get(event);
+ if (buffered && Date.now() - buffered.ts <= ttl) {
+ // Defer one microtask so the subscriber's component has
+ // finished mounting before its handler runs.
+ Promise.resolve().then(() => {
+ try { handler(buffered.payload); } catch (err) {
+ console.error(`[bus] sticky replay for "${event}" threw:`, err);
+ }
+ });
+ }
+ }
+
return () => bus.off(event, handler);
},
@@ -27,6 +53,11 @@ export const bus = {
},
emit(event, payload) {
+ // Stash sticky events so late subscribers (lazy workspaces) can
+ // pick them up after the cold chunk fetch resolves.
+ if (event in STICKY_EVENTS) {
+ _sticky.set(event, { payload, ts: Date.now() });
+ }
const set = _listeners.get(event);
if (!set) return;
// Snapshot so handlers can off() themselves without breaking iteration.
@@ -35,6 +66,15 @@ export const bus = {
console.error(`[bus] handler for "${event}" threw:`, err);
}
}
+ },
+
+ /**
+ * Clear a sticky event so a stale value doesn't replay to a
+ * subscriber that mounts much later. Used by the composer after
+ * its event has been seen (or after a workspace switch).
+ */
+ clearSticky(event) {
+ _sticky.delete(event);
}
};
diff --git a/brainsnn-r3f-app/src/utils/multiTab.js b/brainsnn-r3f-app/src/utils/multiTab.js
index 891bb5d..af1f94a 100644
--- a/brainsnn-r3f-app/src/utils/multiTab.js
+++ b/brainsnn-r3f-app/src/utils/multiTab.js
@@ -43,6 +43,21 @@ function ensureChannel() {
}
export function publish(kind, payload) {
+ // Local fan-out FIRST. BroadcastChannel deliberately does not echo
+ // a message back to its sender (per spec), so same-tab subscribers
+ // would never see their own publishes otherwise. That broke the
+ // common pattern where a util function writes + broadcasts, and
+ // the same tab's panel relies on the broadcast to refresh.
+ const subs = _subs.get(kind);
+ if (subs) {
+ const envelope = { kind, payload, origin: ORIGIN, ts: Date.now() };
+ for (const cb of Array.from(subs)) {
+ try { cb(payload, envelope); } catch (err) {
+ console.warn(`[multiTab] subscriber for "${kind}" threw:`, err);
+ }
+ }
+ }
+ // Cross-tab fan-out.
const ch = ensureChannel();
if (!ch) return;
try {
From c39314ce665ae2a324689eff002d6fc4512ffc6e Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 19 May 2026 23:16:07 +0000
Subject: [PATCH 28/33] feat(engine): runRedTeam + adversarial training into
the firewall worker
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extends the existing firewall worker with two new handlers. Same
worker pool, no new chunks — keeps the connection cost low while
offloading the next two biggest synchronous freezes.
Worker handlers added:
* runRedTeam ({ thresholds, rules })
65-sample corpus × scoring pass at 3 thresholds. ~150 ms on
default hardware; previously blocked the 3D viewer mid-run.
Optional `rules` param serializes/restores around the call so
the main thread's active ruleset propagates without leaking
worker state.
* trainFromRedTeam ({ report, opts })
n-gram lift mining on the report object. Pure compute over
serializable data; no rule state needed.
Main-thread async wrappers:
* src/utils/redTeam.js → runRedTeamAsync(opts)
Skips the worker path if a custom scoreFn is supplied (worker
can't serialize closures). Falls back inline if the pool is
unavailable.
* src/utils/adversarialTraining.js → trainFromRedTeamAsync(report, opts)
Same fallback semantics.
* src/utils/cognitiveFirewall.js → callFirewallWorker(type, payload)
Generic pool dispatcher exposed for sister modules so they can
reuse the same pool without spawning their own workers.
Consumers updated:
* RedTeamPanel — runAll() now awaits runRedTeamAsync. The 20 ms
setTimeout that existed to let the button re-render before the
sync work is no longer needed; UI stays interactive throughout.
* AdversarialTrainingPanel — handleTrain() awaits both the
baseline run and the n-gram mining via the async variants.
Build clean. Firewall worker chunk now hosts four scoring-related
operations instead of three; main bundle unchanged.
---
.../components/AdversarialTrainingPanel.jsx | 11 +++++----
.../src/components/RedTeamPanel.jsx | 12 +++++-----
.../src/utils/adversarialTraining.js | 12 ++++++++++
.../src/utils/cognitiveFirewall.js | 16 +++++++++++++
brainsnn-r3f-app/src/utils/redTeam.js | 22 +++++++++++++++++
.../src/workers/firewall.worker.js | 24 ++++++++++++++++++-
6 files changed, 85 insertions(+), 12 deletions(-)
diff --git a/brainsnn-r3f-app/src/components/AdversarialTrainingPanel.jsx b/brainsnn-r3f-app/src/components/AdversarialTrainingPanel.jsx
index 60652fe..d3f00f1 100644
--- a/brainsnn-r3f-app/src/components/AdversarialTrainingPanel.jsx
+++ b/brainsnn-r3f-app/src/components/AdversarialTrainingPanel.jsx
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import {
getLearnedPatterns, saveLearnedPatterns, clearLearnedPatterns,
- trainFromRedTeam, evaluateWithLearned
+ trainFromRedTeamAsync, evaluateWithLearned
} from '../utils/adversarialTraining';
-import { runRedTeam } from '../utils/redTeam';
+import { runRedTeamAsync } from '../utils/redTeam';
import { recordEvent as recordImmunity, IMMUNITY_EVENTS } from '../utils/immunityScore';
/**
@@ -21,9 +21,10 @@ export default function AdversarialTrainingPanel() {
async function handleTrain() {
setTraining(true);
setResult(null);
- await new Promise((r) => setTimeout(r, 20)); // let UI update
- const baseline = runRedTeam();
- const { learned: newLearned, stats } = trainFromRedTeam(baseline);
+ // Both red team + n-gram mining run in the firewall worker so
+ // the brain keeps rendering during the training pass.
+ const baseline = await runRedTeamAsync();
+ const { learned: newLearned, stats } = await trainFromRedTeamAsync(baseline);
saveLearnedPatterns(newLearned);
setLearned(newLearned);
const after = evaluateWithLearned();
diff --git a/brainsnn-r3f-app/src/components/RedTeamPanel.jsx b/brainsnn-r3f-app/src/components/RedTeamPanel.jsx
index 2d130d2..20a4e3c 100644
--- a/brainsnn-r3f-app/src/components/RedTeamPanel.jsx
+++ b/brainsnn-r3f-app/src/components/RedTeamPanel.jsx
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
-import { runRedTeam, verdict, corpusSize, ATTACK_CATEGORIES } from '../utils/redTeam';
+import { runRedTeamAsync, verdict, corpusSize, ATTACK_CATEGORIES } from '../utils/redTeam';
import { recordEvent as recordImmunity, IMMUNITY_EVENTS } from '../utils/immunityScore';
/**
@@ -22,11 +22,11 @@ export default function RedTeamPanel() {
setRunning(true);
setReport(null);
setProgress(0);
- // Yield to the browser so the button re-renders before the sync work
- await new Promise((r) => setTimeout(r, 20));
- const result = runRedTeam({
- onProgress: (done) => setProgress(done)
- });
+ // Worker variant — main thread stays free, brain keeps rendering.
+ // onProgress isn't supported across postMessage; the bar jumps
+ // from 0 to total when the report lands (still <300 ms on default
+ // hardware so the snap is barely perceptible).
+ const result = await runRedTeamAsync();
setReport(result);
setRunning(false);
setProgress(total);
diff --git a/brainsnn-r3f-app/src/utils/adversarialTraining.js b/brainsnn-r3f-app/src/utils/adversarialTraining.js
index 83c793c..dae5226 100644
--- a/brainsnn-r3f-app/src/utils/adversarialTraining.js
+++ b/brainsnn-r3f-app/src/utils/adversarialTraining.js
@@ -83,6 +83,18 @@ function extractCandidates(text) {
* that the current firewall failed to catch at the given threshold.
* Returns a new learned-pattern list (not yet persisted — caller decides).
*/
+/**
+ * Async sibling. Offloads the n-gram lift mining to the firewall
+ * worker; falls back inline. Pure-data input + output, no rule
+ * state needed.
+ */
+export async function trainFromRedTeamAsync(report, opts = {}) {
+ const { callFirewallWorker } = await import('./cognitiveFirewall.js');
+ const result = await callFirewallWorker('trainFromRedTeam', { report, opts });
+ if (result) return result;
+ return trainFromRedTeam(report, opts);
+}
+
export function trainFromRedTeam(report, { threshold = 0.3, topK = 20, minLift = 3, minAttackCount = 2 } = {}) {
if (!report?.perAttack) return { learned: [], stats: null };
diff --git a/brainsnn-r3f-app/src/utils/cognitiveFirewall.js b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js
index cca2c3b..22c1c86 100644
--- a/brainsnn-r3f-app/src/utils/cognitiveFirewall.js
+++ b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js
@@ -262,6 +262,22 @@ function ensurePool() {
}
}
+/**
+ * Generic worker dispatch for sister modules (redTeam, adversarial-
+ * Training) that want to share the firewall pool without re-spawning
+ * their own workers. Returns the worker result on success, or null
+ * on fallback / no-pool — caller decides whether to run inline.
+ */
+export async function callFirewallWorker(type, payload, transferList) {
+ const pool = ensurePool();
+ if (!pool || pool.degraded) return null;
+ try {
+ return await pool.call(type, payload, transferList);
+ } catch {
+ return null;
+ }
+}
+
export async function scoreContentAsync(text = '') {
if (!text || text.length < ASYNC_THRESHOLD) return scoreContent(text);
const pool = ensurePool();
diff --git a/brainsnn-r3f-app/src/utils/redTeam.js b/brainsnn-r3f-app/src/utils/redTeam.js
index 5ebb97f..74583d7 100644
--- a/brainsnn-r3f-app/src/utils/redTeam.js
+++ b/brainsnn-r3f-app/src/utils/redTeam.js
@@ -132,6 +132,28 @@ const ATTACK_CORPUS = Object.fromEntries(
/**
* Run the full corpus and compute detection stats at multiple thresholds.
*/
+/**
+ * Async sibling of runRedTeam — same shape, runs the corpus through
+ * the shared firewall worker so the 3D viewer keeps rendering during
+ * the ~150 ms scoring pass. Falls back to the inline sync version
+ * when no worker is available (SSR, custom scoreFn, etc).
+ *
+ * Note: the worker uses its own active rules (synced via setRules
+ * beforehand, or supplied inline through opts.rules — serialized).
+ * Callers that pass a custom scoreFn skip the worker path.
+ */
+export async function runRedTeamAsync(opts = {}) {
+ if (opts.scoreFn) return runRedTeam(opts);
+ const { serializeRules, getActiveRules, callFirewallWorker } = await import('./cognitiveFirewall.js');
+ const rules = serializeRules(getActiveRules());
+ const result = await callFirewallWorker('runRedTeam', {
+ thresholds: opts.thresholds,
+ rules
+ });
+ if (result) return result;
+ return runRedTeam(opts);
+}
+
export function runRedTeam({ thresholds = [0.2, 0.3, 0.4], onProgress, scoreFn = scoreContent } = {}) {
const perCategory = {};
const perAttack = [];
diff --git a/brainsnn-r3f-app/src/workers/firewall.worker.js b/brainsnn-r3f-app/src/workers/firewall.worker.js
index 4b5afa3..7d56e01 100644
--- a/brainsnn-r3f-app/src/workers/firewall.worker.js
+++ b/brainsnn-r3f-app/src/workers/firewall.worker.js
@@ -19,6 +19,8 @@ import {
setActiveRules,
getActiveRules
} from '../utils/cognitiveFirewall.js';
+import { runRedTeam } from '../utils/redTeam.js';
+import { trainFromRedTeam } from '../utils/adversarialTraining.js';
handleRequests({
score: async ({ text }) => scoreContent(text || ''),
@@ -42,5 +44,25 @@ handleRequests({
setRules: async ({ rules }) => {
setActiveRules(rules ? deserializeRules(rules) : null);
return { ok: true };
- }
+ },
+
+ // Run the full 65-sample red-team corpus + compute per-threshold
+ // F1 / detection / FPR. Heavy: ~150 ms per ruleset on default
+ // hardware. Worker variant uses ITS active rules (set via setRules
+ // beforehand or supplied inline), not the main thread's.
+ runRedTeam: async ({ thresholds, rules } = {}) => {
+ const prev = rules ? getActiveRules() : null;
+ if (rules) setActiveRules(deserializeRules(rules));
+ try {
+ // Strip onProgress — postMessage can't carry functions, and
+ // panel-side progress is overkill at <200ms total.
+ return runRedTeam({ thresholds });
+ } finally {
+ if (rules) setActiveRules(prev);
+ }
+ },
+
+ // n-gram lift mining over a runRedTeam report. Pure compute on
+ // serializable data; no rule state needed.
+ trainFromRedTeam: async ({ report, opts }) => trainFromRedTeam(report, opts || {})
});
From b0bd74fa8fab49c0d80e04019269f06228cec999 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 19 May 2026 23:18:34 +0000
Subject: [PATCH 29/33] perf(shell): React.memo Topbar / Composer /
WorkspaceTabs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
NewApp re-renders every 180 ms (simulation tick → setState).
Without memo, the entire shell chrome (header + left rail + top
composer) re-rendered ~5×/second even though none of those
components visually depend on the brain state.
Wrapped the three components in React.memo:
* Topbar — depends only on { workspace, firewallResult, immunity,
onShowHelp }. firewallResult / immunity / workspace change on
user action only; onShowHelp was an inline lambda recreated
each render → also memoized in NewApp via useCallback so the
shallow compare actually catches.
* Composer — zero props (state is internal). The simulation tick
no longer redraws the input field.
* WorkspaceTabs — { active, onChange }. Both stable across ticks.
Behavioural diff: none. Pure perf win on weaker devices /
keep-alive workspaces (6 active workspaces × 5 re-renders/sec was
the worst case before).
Build clean. Bigger architectural fix (split session into stable
handlers vs live state via Context) is its own PR if profiler still
shows hot paths in the workspaces themselves.
---
brainsnn-r3f-app/src/shell/Composer.jsx | 7 ++++++-
brainsnn-r3f-app/src/shell/NewApp.jsx | 6 +++++-
brainsnn-r3f-app/src/shell/Topbar.jsx | 8 +++++++-
brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx | 6 +++++-
4 files changed, 23 insertions(+), 4 deletions(-)
diff --git a/brainsnn-r3f-app/src/shell/Composer.jsx b/brainsnn-r3f-app/src/shell/Composer.jsx
index d3fd807..b97bcc3 100644
--- a/brainsnn-r3f-app/src/shell/Composer.jsx
+++ b/brainsnn-r3f-app/src/shell/Composer.jsx
@@ -8,8 +8,11 @@ import { bus } from './bus';
*
* Designed as a single-line ambient input — for the heavy stuff users
* still go into the relevant panel's own textarea.
+ *
+ * Takes no props so re-renders are entirely state-internal — memoize
+ * the export so AppShell's simulation-tick re-renders skip past it.
*/
-export default function Composer() {
+function ComposerImpl() {
const [text, setText] = useState('');
const [mode, setMode] = useState('scan');
@@ -61,3 +64,5 @@ export default function Composer() {
);
}
+
+export default React.memo(ComposerImpl);
diff --git a/brainsnn-r3f-app/src/shell/NewApp.jsx b/brainsnn-r3f-app/src/shell/NewApp.jsx
index a34c081..8e6bcf8 100644
--- a/brainsnn-r3f-app/src/shell/NewApp.jsx
+++ b/brainsnn-r3f-app/src/shell/NewApp.jsx
@@ -518,6 +518,10 @@ export default function NewApp() {
setState((s) => ({ ...s, burst: 20, tick: 0, scenario: 'Replay Burst' }));
}, []);
+ // Stable identity so React.memo'd Topbar can short-circuit re-renders
+ // on every simulation tick.
+ const onShowHelp = useCallback(() => setShowKbHelp(true), []);
+
const modeLabel = mode === 'tribe' ? 'TRIBE v2' : mode === 'eeg' ? 'Live EEG' : 'Simulation';
const session = {
@@ -530,7 +534,7 @@ export default function NewApp() {
// setters used directly
onScrubTimeline: setTimelineIndex,
onSelectRegion,
- onShowHelp: () => setShowKbHelp(true),
+ onShowHelp,
onGifChange,
onReplayBurst,
// bundles
diff --git a/brainsnn-r3f-app/src/shell/Topbar.jsx b/brainsnn-r3f-app/src/shell/Topbar.jsx
index 1506f9d..82eb267 100644
--- a/brainsnn-r3f-app/src/shell/Topbar.jsx
+++ b/brainsnn-r3f-app/src/shell/Topbar.jsx
@@ -9,7 +9,7 @@ const THEME_CYCLE = ['auto', 'dark', 'light'];
* Left: wordmark + active workspace breadcrumb.
* Right: theme toggle, ⌘K hint, keyboard help.
*/
-export default function Topbar({ workspace, firewallResult, immunity, onShowHelp }) {
+function TopbarImpl({ workspace, firewallResult, immunity, onShowHelp }) {
// Mirror the canonical theme state (theme.js owns persistence under
// brainsnn_theme_v1 + applies CSS vars + broadcasts cross-tab).
// Subscribing to BroadcastChannel via theme.js's registerTheme()
@@ -70,3 +70,9 @@ export default function Topbar({ workspace, firewallResult, immunity, onShowHelp
);
}
+
+// Topbar's props change only when the user runs a scan (firewallResult),
+// switches workspace (label), or earns immunity — not every simulation
+// tick. React.memo gates out the cascade. onShowHelp is the only
+// inline lambda; if it gets de-stabilized later, memoize at the caller.
+export default React.memo(TopbarImpl);
diff --git a/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx
index ed46faa..c55f2cd 100644
--- a/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx
+++ b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx
@@ -10,7 +10,7 @@ export const WORKSPACES = [
{ id: 'connect', label: 'Connect', chord: 'gc', glyph: '◓' }
];
-export default function WorkspaceTabs({ active, onChange }) {
+function WorkspaceTabsImpl({ active, onChange }) {
const ref = useRef(null);
const activeIdx = WORKSPACES.findIndex((w) => w.id === active);
@@ -63,3 +63,7 @@ export default function WorkspaceTabs({ active, onChange }) {
);
}
+
+// Tabs depend only on { active, onChange }. AppShell re-renders every
+// 180ms (simulation tick) but neither of these change with it.
+export default React.memo(WorkspaceTabsImpl);
From efd070eb60ddfb1cc0b72011f52b53d6b3232465 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 19 May 2026 23:20:39 +0000
Subject: [PATCH 30/33] feat(engine): CodeBrain search workloads into a
dedicated worker
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sixth worker chunk on the branch. CodeBrain's parse → community
detect → BM25 index pipeline was a synchronous ~1-2 s freeze on
larger pastes (the GitNexus repo trace, for instance). All three
steps are pure compute over JSON-safe data; trivial to push off
the main thread.
* src/workers/search.worker.js
Handlers: parseFiles, analyzeCode (combined parse + Louvain
+ BM25 index), hybridSearch. analyzeCode fuses the three
operations so the panel only pays one postMessage round-trip
for the whole pipeline.
* src/utils/searchWorker.js
Main-thread API: analyzeCodeAsync(files), hybridSearchAsync(
query, index, opts). Lazy pool spawn (single slot — code
analysis is serial). Inline fallback for SSR/test env.
* src/components/CodeBrainPanel.jsx
Refactored: dropped the useMemo over graph (worker now returns
the analyzed shape in one shot), added `analyzing` state.
Semantic search path stays inline because embed() already
proxies through its own worker — double-hopping would cost
more than it saves.
Build emits search.worker-*.js chunk. The five other workers
unchanged. Initial bundle untouched.
Worker chunks on the branch:
firewall.worker (score/scoreWithRules/setRules + runRedTeam
+ trainFromRedTeam)
embeddings.worker (MiniLM via transformers.js)
evolve.worker (Brain Evolve UCB1/Island/MAP-Elites)
attackEvolve.worker (Attack Evolve mutation loop)
search.worker (parse + Louvain + BM25 — this PR)
---
.../src/components/CodeBrainPanel.jsx | 40 +++++-----
brainsnn-r3f-app/src/utils/searchWorker.js | 77 +++++++++++++++++++
brainsnn-r3f-app/src/workers/search.worker.js | 37 +++++++++
3 files changed, 136 insertions(+), 18 deletions(-)
create mode 100644 brainsnn-r3f-app/src/utils/searchWorker.js
create mode 100644 brainsnn-r3f-app/src/workers/search.worker.js
diff --git a/brainsnn-r3f-app/src/components/CodeBrainPanel.jsx b/brainsnn-r3f-app/src/components/CodeBrainPanel.jsx
index b4ac79d..9e034da 100644
--- a/brainsnn-r3f-app/src/components/CodeBrainPanel.jsx
+++ b/brainsnn-r3f-app/src/components/CodeBrainPanel.jsx
@@ -1,7 +1,6 @@
-import React, { useMemo, useState } from 'react';
-import { parseFiles } from '../utils/codeParser';
-import { buildBM25Index, hybridSearch, hybridSearchSemantic } from '../utils/bm25';
-import { detectCommunities } from '../utils/communities';
+import React, { useState } from 'react';
+import { hybridSearchSemantic } from '../utils/bm25';
+import { analyzeCodeAsync, hybridSearchAsync } from '../utils/searchWorker';
import { isReady as embeddingsReady, embed, cosineSimilarity } from '../utils/embeddings';
/**
@@ -17,31 +16,36 @@ import { isReady as embeddingsReady, embed, cosineSimilarity } from '../utils/em
*/
export default function CodeBrainPanel({ onApplyToNetwork }) {
const [input, setInput] = useState(EXAMPLE_INPUT);
- const [graph, setGraph] = useState(null);
+ // `parsed` now holds the FULL analyzed shape (graph + communities + index)
+ // since the worker computes them in one round trip — no need for a
+ // separate useMemo over the graph.
+ const [parsed, setParsed] = useState(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
+ const [analyzing, setAnalyzing] = useState(false);
- const parsed = useMemo(() => {
- if (!graph) return null;
- const communities = detectCommunities({ nodes: graph.nodes, edges: graph.edges });
- const index = buildBM25Index(graph.docs);
- return { ...graph, communities, index };
- }, [graph]);
-
- function handleAnalyze() {
+ async function handleAnalyze() {
const files = parseInput(input);
if (!files.length) {
- setGraph(null);
+ setParsed(null);
return;
}
- const result = parseFiles(files);
- setGraph(result);
- setResults([]);
+ setAnalyzing(true);
+ try {
+ const result = await analyzeCodeAsync(files);
+ setParsed(result);
+ setResults([]);
+ } finally {
+ setAnalyzing(false);
+ }
}
async function handleSearch() {
if (!parsed?.index || !query.trim()) return;
if (embeddingsReady()) {
+ // Semantic path still runs inline — it needs embed() which
+ // marshals through its own worker; double-hopping a function
+ // across two postMessage boundaries is more cost than benefit.
const { results: ranked } = await hybridSearchSemantic(query, parsed.index, {
topK: 8,
embedFn: embed,
@@ -49,7 +53,7 @@ export default function CodeBrainPanel({ onApplyToNetwork }) {
});
setResults(ranked);
} else {
- setResults(hybridSearch(query, parsed.index, { topK: 8 }));
+ setResults(await hybridSearchAsync(query, parsed.index, { topK: 8 }));
}
}
diff --git a/brainsnn-r3f-app/src/utils/searchWorker.js b/brainsnn-r3f-app/src/utils/searchWorker.js
new file mode 100644
index 0000000..e452394
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/searchWorker.js
@@ -0,0 +1,77 @@
+/**
+ * searchWorker — main-thread API for the CodeBrain worker.
+ *
+ * Spawns a dedicated worker on first use (lazy), keeps it alive for
+ * subsequent calls. The CodeBrain panel typically does
+ * analyze → many searches → re-analyze; reusing the worker avoids
+ * the worker-spawn cost on each query.
+ *
+ * Falls back to inline (sync) when Workers are unavailable.
+ */
+
+import { createPool } from './workerPool';
+import { parseFiles as parseFilesInline } from './codeParser';
+import {
+ buildBM25Index as buildBM25IndexInline,
+ hybridSearch as hybridSearchInline
+} from './bm25';
+import { detectCommunities as detectCommunitiesInline } from './communities';
+
+let _pool = null;
+
+function ensurePool() {
+ if (_pool) return _pool;
+ if (typeof window === 'undefined') return null;
+ try {
+ _pool = createPool(
+ () => new Worker(new URL('../workers/search.worker.js', import.meta.url), { type: 'module' }),
+ {
+ size: 1, // serial code analysis fits one slot
+ fallback: (type, payload) => {
+ if (type === 'parseFiles') return parseFilesInline(payload?.files || []);
+ if (type === 'analyzeCode') {
+ const graph = parseFilesInline(payload?.files || []);
+ const communities = detectCommunitiesInline({ nodes: graph.nodes, edges: graph.edges });
+ const index = buildBM25IndexInline(graph.docs);
+ return { ...graph, communities, index };
+ }
+ if (type === 'hybridSearch') {
+ return hybridSearchInline(payload?.query || '', payload?.index, { topK: payload?.topK || 10 });
+ }
+ return null;
+ }
+ }
+ );
+ return _pool;
+ } catch { return null; }
+}
+
+export async function analyzeCodeAsync(files) {
+ const pool = ensurePool();
+ if (!pool || pool.degraded) {
+ const graph = parseFilesInline(files || []);
+ const communities = detectCommunitiesInline({ nodes: graph.nodes, edges: graph.edges });
+ const index = buildBM25IndexInline(graph.docs);
+ return { ...graph, communities, index };
+ }
+ try {
+ return await pool.call('analyzeCode', { files });
+ } catch {
+ const graph = parseFilesInline(files || []);
+ const communities = detectCommunitiesInline({ nodes: graph.nodes, edges: graph.edges });
+ const index = buildBM25IndexInline(graph.docs);
+ return { ...graph, communities, index };
+ }
+}
+
+export async function hybridSearchAsync(query, index, { topK = 10 } = {}) {
+ const pool = ensurePool();
+ if (!pool || pool.degraded) {
+ return hybridSearchInline(query || '', index, { topK });
+ }
+ try {
+ return await pool.call('hybridSearch', { query, index, topK });
+ } catch {
+ return hybridSearchInline(query || '', index, { topK });
+ }
+}
diff --git a/brainsnn-r3f-app/src/workers/search.worker.js b/brainsnn-r3f-app/src/workers/search.worker.js
new file mode 100644
index 0000000..6cd7ea3
--- /dev/null
+++ b/brainsnn-r3f-app/src/workers/search.worker.js
@@ -0,0 +1,37 @@
+/**
+ * search.worker.js — CodeBrain workloads off the main thread.
+ *
+ * The Layer 20 panel parses source files, builds a code graph,
+ * detects Louvain communities, and indexes for BM25 + trigram
+ * hybrid search. On large pastes this freezes the UI for a second
+ * or two. All three steps are pure compute over JSON-safe data —
+ * perfect worker fodder.
+ *
+ * Handlers:
+ * parseFiles ({ files }) → graph
+ * analyzeCode ({ files }) → { graph, communities, index }
+ * hybridSearch ({ query, index, topK }) → results
+ *
+ * Communities + BM25 are folded into `analyzeCode` because the
+ * CodeBrain panel always runs them together; saves a postMessage
+ * round-trip.
+ */
+
+import { handleRequests } from '../utils/workerPool.js';
+import { parseFiles } from '../utils/codeParser.js';
+import { buildBM25Index, hybridSearch } from '../utils/bm25.js';
+import { detectCommunities } from '../utils/communities.js';
+
+handleRequests({
+ parseFiles: async ({ files }) => parseFiles(files || []),
+
+ analyzeCode: async ({ files }) => {
+ const graph = parseFiles(files || []);
+ const communities = detectCommunities({ nodes: graph.nodes, edges: graph.edges });
+ const index = buildBM25Index(graph.docs);
+ return { ...graph, communities, index };
+ },
+
+ hybridSearch: async ({ query, index, topK }) =>
+ hybridSearch(query || '', index, { topK: topK || 10 })
+});
From 8af8bf41c4beaecb41e1ecfebdb338d70c5abb79 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 19 May 2026 23:21:17 +0000
Subject: [PATCH 31/33] =?UTF-8?q?chore(perf):=20drop=20FeedbackPanel=20pol?=
=?UTF-8?q?ling=20=E2=80=94=20live=20subscription=20handles=20it?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Beast PR #11 follow-up cleanup. FeedbackPanel kept a 3-second
setInterval as a safety net when the multiTab subscription was
added. Now that publish() fan-outs same-tab subscribers (fixed in
05eba01), the live subscription is the source of truth and the
poll is pure waste — wakes the main thread every 3 seconds on
every browser tab even when nothing has happened.
Single-file change. listFeedback / calibrationReport still run
on mount + on every 'feedback:changed' event.
---
brainsnn-r3f-app/src/components/FeedbackPanel.jsx | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx
index 02d1ea5..afd9133 100644
--- a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx
+++ b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx
@@ -11,14 +11,11 @@ export default function FeedbackPanel() {
setRows(listFeedback());
}, []);
- useEffect(() => {
- const id = setInterval(refresh, 3000);
- return () => clearInterval(id);
- }, [refresh]);
-
// feedback.js publishes 'feedback:changed' on each recordFeedback /
// clearFeedback. Locked writes are async, so this subscription
- // patches the UI right after the write commits (no 3s wait).
+ // patches the UI right after the write commits. Replaces the
+ // previous 3s polling interval — live updates make polling
+ // redundant (saves a setInterval on every panel mount).
useEffect(() => subscribeMultiTab('feedback:changed', refresh), [refresh]);
function wipe() {
From bb714854830100a4ce833821b9c1694c8477bf1c Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 19 May 2026 23:30:21 +0000
Subject: [PATCH 32/33] fix(pwa): gate controllerchange before showing 'new
version' banner
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Codex P2 review fix on PR #46.
controllerchange fires on the FIRST service-worker install too (no
previous controller → a fresh controller), not just on updates.
That meant first-time visitors got the "A new version is ready —
Reload" banner before they had done anything — a confusing prompt
to reload an app they just opened.
Fix in src/utils/swUpdate.js:
* `sawUpdate` flag inside track() flips to true only when an
updatefound or statechange event indicates a real update
transition (installing while a controller already exists).
* controllerchange handler now broadcasts 'waiting' ONLY when
sawUpdate is true OR reg.waiting is set. First-install
controllerchange falls through silently.
* As a defensive measure, the `installed` branch of the
statechange listener flips sawUpdate true when it detects an
installing worker arriving while a controller already exists
— covers the edge case where updatefound fires before
track() has wired its sawUpdate flag.
No behavioural change for actual deploys — banner still appears
when a new worker is waiting after an update.
---
brainsnn-r3f-app/src/utils/swUpdate.js | 21 ++++++++++++++++++---
1 file changed, 18 insertions(+), 3 deletions(-)
diff --git a/brainsnn-r3f-app/src/utils/swUpdate.js b/brainsnn-r3f-app/src/utils/swUpdate.js
index f88c740..ab0668b 100644
--- a/brainsnn-r3f-app/src/utils/swUpdate.js
+++ b/brainsnn-r3f-app/src/utils/swUpdate.js
@@ -30,6 +30,11 @@ function broadcast(patch) {
function track(reg) {
_registration = reg;
+ // True once we've actually observed an update transition. Guards
+ // against `controllerchange` firing on first-time install (no prior
+ // controller → a fresh user would otherwise see "new version
+ // ready" before they've done anything).
+ let sawUpdate = false;
// Already waiting on page load — happens when a SW updated while
// the tab was closed.
@@ -43,6 +48,10 @@ function track(reg) {
reg.addEventListener('updatefound', () => {
const installing = reg.installing;
if (!installing) return;
+ // Only flag this as an actual update if there was already a
+ // controller before — otherwise this is the first-install path
+ // and the user doesn't need a reload prompt.
+ if (navigator.serviceWorker.controller) sawUpdate = true;
broadcast({ status: 'pending' });
installing.addEventListener('statechange', () => {
if (installing.state === 'installed') {
@@ -50,6 +59,7 @@ function track(reg) {
// waiting. If not, it's a first-install and goes straight to
// active — no need to nudge the user.
if (navigator.serviceWorker.controller) {
+ sawUpdate = true;
broadcast({ status: 'waiting' });
} else {
broadcast({ status: 'active' });
@@ -63,11 +73,16 @@ function track(reg) {
});
// Worker took over — reload anything stale at the next nav.
+ // controllerchange ALSO fires on the very first SW install (no
+ // previous controller → a fresh controller). Gate the 'waiting'
+ // emit so we only surface the reload banner when an actual update
+ // has been observed (sawUpdate) OR a worker is currently waiting.
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
- // Don't auto-reload — the user might be mid-edit. Surface
- // 'waiting' so the toast offers a manual reload.
- broadcast({ status: 'waiting' });
+ const reallyWaiting = sawUpdate || !!_registration?.waiting;
+ if (reallyWaiting) {
+ broadcast({ status: 'waiting' });
+ }
});
}
}
From d369e09ef66215d0f84f84009604686b76bc1a23 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 19 May 2026 23:42:52 +0000
Subject: [PATCH 33/33] =?UTF-8?q?fix:=203=20Codex=20review=20issues=20?=
=?UTF-8?q?=E2=80=94=20palette=20workspace=20mount,=20onboarding=20retry,?=
=?UTF-8?q?=20embeddings=20localStorage=20fallback?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
P1 — CommandPalette layer jumps no-op on unvisited workspaces.
findAndFlashPanel searched the DOM directly without dispatching
shell:goto, so picking a layer from an unvisited workspace
silently failed (the panel tree wasn't mounted yet). The
flashLayer helper at src/utils/flashLayer.js already does this
correctly — dispatches shell:goto, waits two rAFs, then scrolls
+ rings. CommandPalette never adopted it.
CommandPalette now uses flashLayer(row.id) on pick. Inline
findAndFlashPanel deleted. Mouse + keyboard layer jumps now
work to any workspace regardless of visit history.
P2 — OnboardingWalkthrough single-rAF lookup fails on lazy panels.
After dispatching shell:goto, the walkthrough waited one rAF
then queried document.querySelector(target). For destinations
inside lazy-loaded workspace chunks (e.g. .snapshot-panel in
BrainWorkspace) the chunk takes 100s of ms to fetch on a cold
first navigate — one rAF is far too early, target query returns
null, console.warn fires, walkthrough silently skips the step.
Now polls every 50ms for up to 2s (40 attempts). First attempt
still runs on rAF so the workspace swap settles before querying.
Each attempt cancels cleanly on step change / unmount.
P2 — embeddingsStore loses vectors under localStorage fallback.
store.js falls back to JSON-backed localStorage when IndexedDB
is unavailable (private mode in Safari, etc). JSON.stringify
on a Float32Array produces a numerically-keyed plain object
(`{"0":1.23,"1":-0.5}`). On read, Float32Array.from(obj) sees
no .length and returns an EMPTY typed array — every "cached"
vector becomes zero-similarity and semantic features silently
degrade.
Fix:
* setCached now always serializes via Array.from(vec) so the
stored shape is a JSON-safe plain Array regardless of
backend.
* New normalizeVec() helper handles Float32Array (IDB path),
Array (new fallback writes), and numeric-keyed objects
(legacy fallback writes that pre-date this fix). Returns
null for genuinely empty values so the caller doesn't get
a zero-length vector.
* On a successful read from an object-shaped row, the cache
is rewritten as a plain Array so subsequent reads round-trip
correctly.
Build clean.
---
.../src/components/CommandPalette.jsx | 34 ++++-----------
.../src/components/OnboardingWalkthrough.jsx | 41 ++++++++++++++-----
brainsnn-r3f-app/src/utils/embeddingsStore.js | 40 +++++++++++++++---
3 files changed, 74 insertions(+), 41 deletions(-)
diff --git a/brainsnn-r3f-app/src/components/CommandPalette.jsx b/brainsnn-r3f-app/src/components/CommandPalette.jsx
index f19ca7d..49d8d45 100644
--- a/brainsnn-r3f-app/src/components/CommandPalette.jsx
+++ b/brainsnn-r3f-app/src/components/CommandPalette.jsx
@@ -1,14 +1,17 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { LAYER_CATALOG, LAYER_GROUPS } from '../utils/layerCatalog';
import { bus } from '../shell/bus';
+import { flashLayer } from '../utils/flashLayer';
/**
* Layer 92 — Command Palette
*
* ⌘K / Ctrl-K opens a centered fuzzy-search overlay. Each match is
- * a layer entry; selecting scrolls the matching panel into view (by
- * searching the DOM for a panel whose eyebrow-label contains the
- * layer id) and briefly highlights it.
+ * a layer entry; selecting routes through src/utils/flashLayer.js
+ * which dispatches `shell:goto` to mount the destination workspace
+ * BEFORE scrolling — without that hop, layer jumps to unvisited
+ * workspaces would silently no-op (the panel tree isn't in the DOM
+ * until its workspace mounts).
*/
function fuzzyScore(needle, hay) {
@@ -26,27 +29,6 @@ function fuzzyScore(needle, hay) {
return 0.5;
}
-function findAndFlashPanel(layerId) {
- try {
- // Every layer panel has an eyebrow "Layer N ·" at its top; find it
- const re = new RegExp(`\\blayer\\s*${layerId}\\b`, 'i');
- const labels = document.querySelectorAll('.eyebrow');
- for (const el of labels) {
- if (re.test(el.textContent || '')) {
- const panel = el.closest('.panel');
- if (!panel) continue;
- panel.scrollIntoView({ behavior: 'smooth', block: 'center' });
- const prev = panel.style.boxShadow;
- panel.style.transition = 'box-shadow 400ms ease';
- panel.style.boxShadow = '0 0 0 3px rgba(90,212,255,0.6)';
- setTimeout(() => { panel.style.boxShadow = prev; }, 1400);
- return true;
- }
- }
- } catch { /* noop */ }
- return false;
-}
-
export default function CommandPalette() {
const [open, setOpen] = useState(false);
const [q, setQ] = useState('');
@@ -90,7 +72,9 @@ export default function CommandPalette() {
function pick(row) {
setOpen(false);
setQ('');
- setTimeout(() => findAndFlashPanel(row.id), 50);
+ // flashLayer dispatches shell:goto, waits two rAFs for the
+ // workspace to mount, then scrolls + rings the matched panel.
+ flashLayer(row.id);
}
if (!open) return null;
diff --git a/brainsnn-r3f-app/src/components/OnboardingWalkthrough.jsx b/brainsnn-r3f-app/src/components/OnboardingWalkthrough.jsx
index a58d366..b6e7d98 100644
--- a/brainsnn-r3f-app/src/components/OnboardingWalkthrough.jsx
+++ b/brainsnn-r3f-app/src/components/OnboardingWalkthrough.jsx
@@ -85,28 +85,49 @@ export default function OnboardingWalkthrough() {
};
// Highlight target element. When the new shell is active and the
- // target lives in a different workspace, dispatch shell:goto first
- // and wait one frame for that workspace to mount before querying
- // the DOM.
+ // target lives in a different workspace, dispatch shell:goto first.
+ // Then poll for the target — the destination panel is lazy-loaded
+ // inside the workspace chunk, so a single rAF is too early on
+ // cold first navigation (the chunk fetch takes 100s of ms). Retry
+ // every 50ms up to 2s before giving up.
useEffect(() => {
if (step < 0 || step >= STEPS.length) return;
const target = STEPS[step].target;
if (!target) return;
const ws = ANCHOR_WORKSPACE[target];
if (ws) bus.emit('shell:goto', { workspace: ws });
+
let cleanup = () => {};
- const raf = requestAnimationFrame(() => {
+ let cancelled = false;
+ let attempts = 0;
+ const MAX_ATTEMPTS = 40; // 40 × 50ms = 2 s
+ let timer = null;
+
+ const tryLand = () => {
+ if (cancelled) return;
const el = document.querySelector(target);
- if (!el) {
- if (typeof console !== 'undefined') console.warn('[onboarding] target missing:', target);
+ if (el) {
+ el.classList.add('onboard-highlight');
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ cleanup = () => el.classList.remove('onboard-highlight');
+ return;
+ }
+ attempts += 1;
+ if (attempts >= MAX_ATTEMPTS) {
+ if (typeof console !== 'undefined') console.warn('[onboarding] target missing after retries:', target);
return;
}
- el.classList.add('onboard-highlight');
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
- cleanup = () => el.classList.remove('onboard-highlight');
- });
+ timer = setTimeout(tryLand, 50);
+ };
+
+ // Start on the next rAF so the workspace switch from shell:goto
+ // has a chance to settle before the first lookup.
+ const raf = requestAnimationFrame(tryLand);
+
return () => {
+ cancelled = true;
cancelAnimationFrame(raf);
+ if (timer) clearTimeout(timer);
cleanup();
};
}, [step]);
diff --git a/brainsnn-r3f-app/src/utils/embeddingsStore.js b/brainsnn-r3f-app/src/utils/embeddingsStore.js
index 8e8f603..94f2cc5 100644
--- a/brainsnn-r3f-app/src/utils/embeddingsStore.js
+++ b/brainsnn-r3f-app/src/utils/embeddingsStore.js
@@ -75,17 +75,40 @@ async function pruneIfNeeded() {
} catch { /* noop */ }
}
+/**
+ * Reconstruct a Float32Array from whatever shape store.get returned.
+ * - Float32Array: pass through (IDB structured-clone preserves it)
+ * - Array: cheap typed reconstruction
+ * - Object: localStorage fallback path — JSON.stringify turns
+ * Float32Array into { "0": x, "1": y, ... }, and Float32Array.from
+ * on a plain object returns an empty array because the object has
+ * no .length. Rebuild by reading numeric keys in order.
+ */
+function normalizeVec(v) {
+ if (v == null) return null;
+ if (v instanceof Float32Array) return v;
+ if (Array.isArray(v)) return Float32Array.from(v);
+ if (typeof v === 'object') {
+ const keys = Object.keys(v).filter((k) => /^\d+$/.test(k));
+ if (!keys.length) return null;
+ keys.sort((a, b) => Number(a) - Number(b));
+ return Float32Array.from(keys, (k) => v[k]);
+ }
+ return null;
+}
+
export async function getCached(hash) {
await migrateLegacy();
try {
const row = await store().get(hash);
if (!row || !row.vec) return null;
+ const vec = normalizeVec(row.vec);
+ if (!vec || !vec.length) return null;
// Refresh lastAccessed in the background; don't block the read.
- const refreshed = { vec: row.vec, t: Date.now() };
- store().set(hash, refreshed).catch(() => { /* noop */ });
- // IDB returns plain typed arrays directly; if a structured-clone
- // path ever boxes it, restore the Float32Array view.
- return row.vec instanceof Float32Array ? row.vec : Float32Array.from(row.vec);
+ // Re-write as plain Array so the next read on a localStorage-fallback
+ // engine round-trips through JSON correctly.
+ store().set(hash, { vec: Array.from(vec), t: Date.now() }).catch(() => { /* noop */ });
+ return vec;
} catch {
return null;
}
@@ -93,7 +116,12 @@ export async function getCached(hash) {
export async function setCached(hash, vec) {
try {
- await store().set(hash, { vec, t: Date.now() });
+ // Always serialize as a plain Array. IDB stores it fine (structured
+ // clone) and the localStorage fallback path needs JSON-safe shape —
+ // a raw Float32Array there serializes to a numerically-indexed
+ // object that won't round-trip back.
+ const safe = vec instanceof Float32Array ? Array.from(vec) : vec;
+ await store().set(hash, { vec: safe, t: Date.now() });
_writeCount++;
// Amortized prune — every ~100 writes check size.
if (_writeCount % 100 === 0) pruneIfNeeded();