helper.js 5.9 KB

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