diff --git a/skills/brainstorming/scripts/frame-template.html b/skills/brainstorming/scripts/frame-template.html
index 6325ef91..51575a1e 100644
--- a/skills/brainstorming/scripts/frame-template.html
+++ b/skills/brainstorming/scripts/frame-template.html
@@ -73,8 +73,8 @@
flex-shrink: 0;
}
.header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
- .header .status { font-size: 0.7rem; color: var(--success); display: flex; align-items: center; gap: 0.4rem; }
- .header .status::before { content: ''; width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
+ .header .status { font-size: 0.7rem; color: var(--status-color, var(--success)); display: flex; align-items: center; gap: 0.4rem; }
+ .header .status::before { content: ''; width: 6px; height: 6px; background: var(--status-color, var(--success)); border-radius: 50%; }
.main { flex: 1; overflow-y: auto; }
#frame-content { padding: 2rem; min-height: 100%; }
diff --git a/skills/brainstorming/scripts/helper.js b/skills/brainstorming/scripts/helper.js
index 111f97f5..456e9150 100644
--- a/skills/brainstorming/scripts/helper.js
+++ b/skills/brainstorming/scripts/helper.js
@@ -1,26 +1,98 @@
(function() {
+ const MIN_RECONNECT_MS = 500;
+ const MAX_RECONNECT_MS = 30000;
+ const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected
+
+ // Pure: next backoff delay (doubles, capped). Exported for unit tests.
+ function nextReconnectDelay(current, max) {
+ return Math.min(current * 2, max);
+ }
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS };
+ }
+
+ // Everything below is browser-only; bail out when loaded in Node (tests).
+ if (typeof window === 'undefined') return;
+
const WS_URL = 'ws://' + window.location.host;
let ws = null;
let eventQueue = [];
+ let reconnectDelay = MIN_RECONNECT_MS;
+ let reconnectTimer = null;
+ let disconnectedSince = null;
+ let everConnected = false;
+ let tombstoneShown = false;
+
+ // Reflect connection state in the frame's status pill (absent on full-doc screens).
+ function setStatus(state) {
+ const el = document.querySelector('.status');
+ if (!el) return;
+ const map = {
+ connected: ['Connected', 'var(--success)'],
+ reconnecting: ['Reconnecting…', 'var(--warning)'],
+ disconnected: ['Disconnected', 'var(--error)']
+ };
+ const [text, color] = map[state] || map.disconnected;
+ el.textContent = text;
+ el.style.setProperty('--status-color', color);
+ }
+
+ // Self-styled so it works on framed and full-document screens alike.
+ function showTombstone() {
+ if (tombstoneShown) return;
+ tombstoneShown = true;
+ const el = document.createElement('div');
+ el.id = 'bs-tombstone';
+ el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' +
+ 'align-items:center;justify-content:center;padding:2rem;text-align:center;' +
+ 'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif';
+ el.innerHTML = '
' +
+ '
Companion paused
' +
+ '
This brainstorm companion has stopped. ' +
+ 'Ask your coding agent to bring it back — this page reconnects automatically.
';
+ if (document.body) document.body.appendChild(el);
+ }
function connect() {
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
+ setStatus(everConnected ? 'reconnecting' : 'connected');
ws = new WebSocket(WS_URL);
ws.onopen = () => {
+ const recovered = tombstoneShown;
+ everConnected = true;
+ disconnectedSince = null;
+ reconnectDelay = MIN_RECONNECT_MS;
+ tombstoneShown = false;
+ setStatus('connected');
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
eventQueue = [];
+ // Recovered from a tombstoned outage (e.g. the server restarted on the same
+ // port) — reload to pick up the restarted server's current screen.
+ if (recovered) window.location.reload();
};
ws.onmessage = (msg) => {
- const data = JSON.parse(msg.data);
- if (data.type === 'reload') {
- window.location.reload();
- }
+ let data;
+ try { data = JSON.parse(msg.data); } catch (e) { return; }
+ if (data.type === 'reload') window.location.reload();
};
ws.onclose = () => {
- setTimeout(connect, 1000);
+ ws = null;
+ if (disconnectedSince === null) disconnectedSince = Date.now();
+ if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) {
+ setStatus('disconnected');
+ showTombstone();
+ } else {
+ setStatus('reconnecting');
+ }
+ reconnectTimer = setTimeout(connect, reconnectDelay);
+ reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS);
};
+
+ // Let onclose own reconnection so we don't schedule it twice.
+ ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
function sendEvent(event) {
diff --git a/tests/brainstorm-server/helper.test.js b/tests/brainstorm-server/helper.test.js
new file mode 100644
index 00000000..e897afcd
--- /dev/null
+++ b/tests/brainstorm-server/helper.test.js
@@ -0,0 +1,84 @@
+/**
+ * Tests for the injected browser client (helper.js).
+ *
+ * helper.js runs in the browser, so its DOM behaviour is exercised live; here we
+ * unit-test the pure reconnect-backoff function it exports and assert that the
+ * reconnect / status / tombstone wiring is present.
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const HELPER = path.join(__dirname, '../../skills/brainstorming/scripts/helper.js');
+
+const src = fs.readFileSync(HELPER, 'utf-8');
+
+// helper.js is browser code, and the repo is an ES module package, so a plain
+// require() won't surface its exports. Evaluate the source in a CommonJS sandbox
+// with no `window`, so only the exported pure helpers run (not the browser code).
+const moduleShim = { exports: {} };
+new Function('module', src)(moduleShim);
+const { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS } = moduleShim.exports;
+
+let passed = 0, failed = 0;
+function test(name, fn) {
+ try { fn(); console.log(` PASS: ${name}`); passed++; }
+ catch (e) { console.log(` FAIL: ${name}`); console.log(` ${e.message}`); failed++; }
+}
+
+console.log('\n--- Backoff (pure) ---');
+
+test('doubles the delay each call', () => {
+ assert.strictEqual(nextReconnectDelay(500, 30000), 1000);
+ assert.strictEqual(nextReconnectDelay(1000, 30000), 2000);
+ assert.strictEqual(nextReconnectDelay(2000, 30000), 4000);
+});
+
+test('caps at the maximum', () => {
+ assert.strictEqual(nextReconnectDelay(20000, 30000), 30000);
+ assert.strictEqual(nextReconnectDelay(30000, 30000), 30000);
+});
+
+test('full progression from MIN caps at MAX and never exceeds it', () => {
+ const seq = [MIN_RECONNECT_MS];
+ let d = MIN_RECONNECT_MS;
+ for (let i = 0; i < 10; i++) { d = nextReconnectDelay(d, MAX_RECONNECT_MS); seq.push(d); }
+ assert.strictEqual(seq[0], 500);
+ assert.deepStrictEqual(seq.slice(0, 7), [500, 1000, 2000, 4000, 8000, 16000, 30000]);
+ assert(seq.every(v => v <= MAX_RECONNECT_MS), 'never exceeds max');
+ assert.strictEqual(seq[seq.length - 1], 30000, 'settles at the cap');
+});
+
+test('exposes sane constants', () => {
+ assert.strictEqual(MIN_RECONNECT_MS, 500);
+ assert.strictEqual(MAX_RECONNECT_MS, 30000);
+ assert(TOMBSTONE_AFTER_MS >= 5000, 'tombstone grace is at least a few seconds');
+});
+
+console.log('\n--- Wiring (source) ---');
+
+test('reflects all three connection states', () => {
+ assert(/Connected/.test(src) && /Reconnecting/.test(src) && /Disconnected/.test(src),
+ 'should set Connected / Reconnecting / Disconnected status');
+ assert(src.includes("setProperty('--status-color'"), 'drives the status dot via --status-color');
+});
+
+test('renders a tombstone overlay when paused', () => {
+ assert(src.includes('bs-tombstone'), 'creates the tombstone element');
+ assert(/Companion paused/.test(src), 'tombstone explains the companion paused');
+});
+
+test('hardens reconnection (onerror, null socket, clears pending timer)', () => {
+ assert(src.includes('onerror'), 'handles onerror');
+ assert(/ws = null/.test(src), 'nulls the socket on close so sendEvent queues');
+ assert(src.includes('clearTimeout'), 'clears a pending reconnect before scheduling another');
+ assert(src.includes('nextReconnectDelay'), 'uses exponential backoff for reconnects');
+});
+
+test('reloads on recovery and on reload messages', () => {
+ assert(/location\.reload\(\)/.test(src), 'reloads to pick up restarted/updated content');
+});
+
+console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
+if (failed > 0) process.exit(1);
diff --git a/tests/brainstorm-server/package.json b/tests/brainstorm-server/package.json
index a4044701..52ff3aed 100644
--- a/tests/brainstorm-server/package.json
+++ b/tests/brainstorm-server/package.json
@@ -2,7 +2,7 @@
"name": "brainstorm-server-tests",
"version": "1.0.0",
"scripts": {
- "test": "node ws-protocol.test.js && node server.test.js && node lifecycle.test.js && bash stop-server.test.sh"
+ "test": "node ws-protocol.test.js && node helper.test.js && node server.test.js && node lifecycle.test.js && bash stop-server.test.sh"
},
"dependencies": {
"ws": "^8.19.0"