helper.js 5.5 KB

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