Skill Tracer
Skills › Superpowers › brainstorming › helper.js

helper.js

167 lines · skills/brainstorming/scripts/helper.js at 2ce8aa1

1(function() {
2 const MIN_RECONNECT_MS = 500;
3 const MAX_RECONNECT_MS = 30000;
4 const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected
5
6 // Pure: next backoff delay (doubles, capped). Exported for unit tests.
7 function nextReconnectDelay(current, max) {
8 return Math.min(current * 2, max);
9 }
10 if (typeof module !== 'undefined' && module.exports) {
11 module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS };
12 }
14 // Everything below is browser-only; bail out when loaded in Node (tests).
15 if (typeof window === 'undefined') return;
17 let ws = null;
18 let eventQueue = [];
19 let reconnectDelay = MIN_RECONNECT_MS;
20 let reconnectTimer = null;
21 let disconnectedSince = null;
22 let everConnected = false;
23 let tombstoneShown = false;
25 function sessionKey() {
26 try {
27 return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key');
28 } catch (e) {}
29 return null;
30 }
32 function websocketUrl() {
33 const key = sessionKey();
34 return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : '');
35 }
37 function reloadAfterRecovery() {
38 const key = sessionKey();
39 if (key) {
40 window.location.replace('/?key=' + encodeURIComponent(key));
41 } else {
42 window.location.reload();
43 }
44 }
46 // Reflect connection state in the frame's status pill (absent on full-doc screens).
47 function setStatus(state) {
48 const el = document.querySelector('.status');
49 if (!el) return;
50 const map = {
51 connecting: ['Connecting…', 'var(--text-tertiary)'],
52 connected: ['Connected', 'var(--success)'],
53 reconnecting: ['Reconnecting…', 'var(--warning)'],
54 disconnected: ['Disconnected', 'var(--error)']
55 };
56 const [text, color] = map[state] || map.disconnected;
57 el.textContent = text;
58 el.style.setProperty('--status-color', color);
59 }
61 // Self-styled so it works on framed and full-document screens alike.
62 function showTombstone() {
63 if (tombstoneShown) return;
64 tombstoneShown = true;
65 const el = document.createElement('div');
66 el.id = 'bs-tombstone';
67 el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' +
68 'align-items:center;justify-content:center;padding:2rem;text-align:center;' +
69 'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif';
70 el.innerHTML = '<div style="max-width:480px">' +
71 '<h2 style="margin:0 0 .5rem;font-weight:600">Companion paused</h2>' +
72 '<p style="margin:0;opacity:.85">This brainstorm companion has stopped. ' +
73 'Ask your coding agent to bring it back — this page reconnects automatically.</p></div>';
74 if (document.body) document.body.appendChild(el);
75 }
77 function connect() {
78 if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
79 setStatus(everConnected ? 'reconnecting' : 'connecting');
80 ws = new WebSocket(websocketUrl());
82 ws.onopen = () => {
83 const recovered = tombstoneShown;
84 everConnected = true;
85 disconnectedSince = null;
86 reconnectDelay = MIN_RECONNECT_MS;
87 tombstoneShown = false;
88 setStatus('connected');
89 eventQueue.forEach(e => ws.send(JSON.stringify(e)));
90 eventQueue = [];
91 // Recovered from a tombstoned outage (e.g. the server restarted on the same
92 // port) — reload through the keyed bootstrap when possible so the cookie is
93 // refreshed before the visible URL returns to bare /.
94 if (recovered) reloadAfterRecovery();
95 };
97 ws.onmessage = (msg) => {
98 let data;
99 try { data = JSON.parse(msg.data); } catch (e) { return; }
100 if (data.type === 'reload') window.location.reload();
101 };
103 ws.onclose = () => {
104 ws = null;
105 if (disconnectedSince === null) disconnectedSince = Date.now();
106 if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) {
107 setStatus('disconnected');
108 showTombstone();
109 } else {
110 setStatus('reconnecting');
111 }
112 reconnectTimer = setTimeout(connect, reconnectDelay);
113 reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS);
114 };
116 // Let onclose own reconnection so we don't schedule it twice.
117 ws.onerror = () => { try { ws.close(); } catch (e) {} };
118 }
120 function sendEvent(event) {
121 event.timestamp = Date.now();
122 if (ws && ws.readyState === WebSocket.OPEN) {
123 ws.send(JSON.stringify(event));
124 } else {
125 eventQueue.push(event);
126 }
127 }
129 // Capture clicks on choice elements
130 document.addEventListener('click', (e) => {
131 const target = e.target.closest('[data-choice]');
132 if (!target) return;
134 sendEvent({
135 type: 'click',
136 text: target.textContent.trim(),
137 choice: target.dataset.choice,
138 id: target.id || null
139 });
141 });
143 // Frame UI: selection tracking
144 window.selectedChoice = null;
146 window.toggleSelect = function(el) {
147 const container = el.closest('.options') || el.closest('.cards');
148 const multi = container && container.dataset.multiselect !== undefined;
149 if (container && !multi) {
150 container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
151 }
152 if (multi) {
153 el.classList.toggle('selected');
154 } else {
155 el.classList.add('selected');
156 }
157 window.selectedChoice = el.dataset.choice;
158 };
160 // Expose API for explicit use
161 window.brainstorm = {
162 send: sendEvent,
163 choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata })
164 };
166 connect();
167})();