(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 = { connecting: ['Connecting…', 'var(--text-tertiary)'], 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' : 'connecting'); 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) => { let data; try { data = JSON.parse(msg.data); } catch (e) { return; } if (data.type === 'reload') window.location.reload(); }; ws.onclose = () => { 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) { event.timestamp = Date.now(); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(event)); } else { eventQueue.push(event); } } // Capture clicks on choice elements document.addEventListener('click', (e) => { const target = e.target.closest('[data-choice]'); if (!target) return; sendEvent({ type: 'click', text: target.textContent.trim(), choice: target.dataset.choice, id: target.id || null }); // Update indicator bar (defer so toggleSelect runs first) setTimeout(() => { const indicator = document.getElementById('indicator-text'); if (!indicator) return; const container = target.closest('.options') || target.closest('.cards'); const selected = container ? container.querySelectorAll('.selected') : []; if (selected.length === 0) { indicator.textContent = 'Click an option above, then return to the terminal'; } else if (selected.length === 1) { const label = selected[0].querySelector('h3, .content h3, .card-body h3')?.textContent?.trim() || selected[0].dataset.choice; indicator.innerHTML = '' + label + ' selected — return to terminal to continue'; } else { indicator.innerHTML = '' + selected.length + ' selected — return to terminal to continue'; } }, 0); }); // Frame UI: selection tracking window.selectedChoice = null; window.toggleSelect = function(el) { const container = el.closest('.options') || el.closest('.cards'); const multi = container && container.dataset.multiselect !== undefined; if (container && !multi) { container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected')); } if (multi) { el.classList.toggle('selected'); } else { el.classList.add('selected'); } window.selectedChoice = el.dataset.choice; }; // Expose API for explicit use window.brainstorm = { send: sendEvent, choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata }) }; connect(); })();