helper.js 5.8 KB

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