@luckydraw/cumulus 0.28.4 → 0.28.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -234,10 +234,13 @@
234
234
  ' display: inline-flex; align-items: center; gap: 0.3em;',
235
235
  ' background: #2a3a4a; color: #d4e8f8; border: 1px solid #3a5060;',
236
236
  ' border-radius: 1em; padding: 0.15em 0.6em; font-size: 0.82em;',
237
- ' cursor: default; max-width: 32ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;',
237
+ ' cursor: default; max-width: 32ch;',
238
+ '}',
239
+ '.cumulus-interaction-chip .chip-label {',
240
+ ' overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;',
238
241
  '}',
239
242
  '.cumulus-interaction-chip .chip-dismiss {',
240
- ' cursor: pointer; opacity: 0.6; font-size: 0.9em; margin-left: 0.2em;',
243
+ ' cursor: pointer; opacity: 0.6; font-size: 0.9em; margin-left: 0.2em; flex-shrink: 0;',
241
244
  '}',
242
245
  '.cumulus-interaction-chip .chip-dismiss:hover { opacity: 1; }',
243
246
 
@@ -246,10 +249,13 @@
246
249
  ' display: inline-flex; align-items: center; gap: 0.3em;',
247
250
  ' background: #2a3a2a; color: #b8e6b8; border: 1px solid #3a6040;',
248
251
  ' border-radius: 1em; padding: 0.15em 0.6em; font-size: 0.82em;',
249
- ' cursor: default; max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;',
252
+ ' cursor: default; max-width: 40ch;',
253
+ '}',
254
+ '.cumulus-annotation-chip .chip-label {',
255
+ ' overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;',
250
256
  '}',
251
257
  '.cumulus-annotation-chip .chip-dismiss {',
252
- ' cursor: pointer; opacity: 0.6; font-size: 0.9em; margin-left: 0.2em;',
258
+ ' cursor: pointer; opacity: 0.6; font-size: 0.9em; margin-left: 0.2em; flex-shrink: 0;',
253
259
  '}',
254
260
  '.cumulus-annotation-chip .chip-dismiss:hover { opacity: 1; }',
255
261
 
@@ -363,6 +369,46 @@
363
369
  ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;',
364
370
  '}',
365
371
  '.code-block-copy-btn:hover { border-color: #0066cc; color: #ddd; }',
372
+ '.code-block-edit-btn {',
373
+ ' background: transparent;',
374
+ ' border: 1px solid #555;',
375
+ ' border-radius: 0.3em;',
376
+ ' color: #aaa;',
377
+ ' font-size: 0.8em;',
378
+ ' padding: 0.15em 0.55em;',
379
+ ' cursor: pointer;',
380
+ ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;',
381
+ '}',
382
+ '.code-block-edit-btn:hover { border-color: #0066cc; color: #ddd; }',
383
+ '.texitool-embed-container {',
384
+ ' border: 1px solid #3a3a3a;',
385
+ ' border-radius: 0.4em;',
386
+ ' overflow: hidden;',
387
+ ' margin: 0.6em 0;',
388
+ ' background: #1e1e1e;',
389
+ '}',
390
+ '.texitool-embed-container iframe {',
391
+ ' width: 100%; border: none;',
392
+ ' min-height: 400px;',
393
+ ' display: block;',
394
+ '}',
395
+ '.texitool-embed-actions {',
396
+ ' padding: 0.4em 0.85em;',
397
+ ' background: #3d3d3d;',
398
+ ' border-top: 1px solid #4a4a4a;',
399
+ ' display: flex; gap: 8px;',
400
+ '}',
401
+ '.texitool-embed-actions button {',
402
+ ' background: transparent;',
403
+ ' border: 1px solid #555;',
404
+ ' border-radius: 0.3em;',
405
+ ' color: #aaa;',
406
+ ' font-size: 0.8em;',
407
+ ' padding: 0.25em 0.65em;',
408
+ ' cursor: pointer;',
409
+ ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;',
410
+ '}',
411
+ '.texitool-embed-actions button:hover { border-color: #0066cc; color: #ddd; }',
366
412
  '.code-block-wrapper pre {',
367
413
  ' padding: 1em 1.15em; margin: 0;',
368
414
  ' overflow-x: auto;',
@@ -1228,7 +1274,23 @@
1228
1274
 
1229
1275
  // Per-thread store for blex interaction values (poll answers, confirm clicks, etc.)
1230
1276
  // Key: "threadName" → Map<"msgTimestamp:blockIdx", interactionValue>
1277
+ // Backed by localStorage for persistence across page refreshes
1278
+ var BLEX_STORE_KEY = 'cumulus-blex-interactions';
1231
1279
  var blexInteractionStore = {};
1280
+ try {
1281
+ var saved = localStorage.getItem(BLEX_STORE_KEY);
1282
+ if (saved) blexInteractionStore = JSON.parse(saved);
1283
+ } catch (e) {
1284
+ /* ignore corrupt/missing localStorage */
1285
+ }
1286
+
1287
+ function _flushBlexStore() {
1288
+ try {
1289
+ localStorage.setItem(BLEX_STORE_KEY, JSON.stringify(blexInteractionStore));
1290
+ } catch (e) {
1291
+ /* quota exceeded or private browsing */
1292
+ }
1293
+ }
1232
1294
 
1233
1295
  // Extract ~~~blex:TYPE\n{json}\n~~~ fences from text
1234
1296
  // Returns { text: string (with placeholders), blocks: Array<{type, json, idx}> }
@@ -1288,9 +1350,10 @@
1288
1350
  if (handle && handle.onInteraction) {
1289
1351
  // Wire interaction handler
1290
1352
  handle.onInteraction(function (interaction) {
1291
- // Store the interaction value for persistence across re-renders
1353
+ // Store the interaction value for persistence across re-renders + refreshes
1292
1354
  if (store && storeKey && interaction.value !== undefined) {
1293
1355
  store[storeKey] = interaction.value;
1356
+ _flushBlexStore();
1294
1357
  }
1295
1358
 
1296
1359
  // Find the panel — try el.closest first, fall back to document query
@@ -1319,8 +1382,8 @@
1319
1382
  sendBtn.click();
1320
1383
  }
1321
1384
  } else {
1322
- // Deferred: add to chip tray
1323
- addInteractionChip(panel, interaction, handle);
1385
+ // Deferred: add to chip tray (sourceId deduplicates rapid-fire from same block)
1386
+ addInteractionChip(panel, interaction, handle, block.type + '-' + idx);
1324
1387
  }
1325
1388
  }
1326
1389
  });
@@ -1360,36 +1423,45 @@
1360
1423
  }
1361
1424
 
1362
1425
  // Add a deferred interaction chip to the chip tray
1363
- function addInteractionChip(panel, interaction, handle) {
1426
+ // If a chip from the same source already exists, replace it (prevents spam from rapid interactions)
1427
+ function addInteractionChip(panel, interaction, handle, sourceId) {
1364
1428
  if (!panel) return;
1365
1429
  var tray = panel.querySelector('.cumulus-chip-tray');
1366
1430
  if (!tray) return;
1367
1431
 
1432
+ // Replace existing chip from same source instead of appending
1433
+ if (sourceId) {
1434
+ var existing = tray.querySelector(
1435
+ '.cumulus-interaction-chip[data-source="' + sourceId + '"]'
1436
+ );
1437
+ if (existing) existing.remove();
1438
+ }
1439
+
1368
1440
  var chip = document.createElement('span');
1369
1441
  chip.className = 'cumulus-interaction-chip';
1370
1442
  chip.setAttribute('data-serialized', interaction.serialized || '');
1443
+ if (sourceId) chip.setAttribute('data-source', sourceId);
1371
1444
 
1372
1445
  var label = document.createElement('span');
1446
+ label.className = 'chip-label';
1373
1447
  label.textContent =
1374
1448
  (interaction.icon || '\u2022') +
1375
1449
  ' ' +
1376
1450
  (interaction.summary || interaction.serialized || '').substring(0, 40);
1377
1451
  chip.appendChild(label);
1378
1452
 
1379
- if (interaction.revertable) {
1380
- var dismiss = document.createElement('span');
1381
- dismiss.className = 'chip-dismiss';
1382
- dismiss.textContent = '\u00d7';
1383
- dismiss.addEventListener('click', function () {
1384
- if (handle && handle.revertInteraction) {
1385
- try {
1386
- handle.revertInteraction(interaction);
1387
- } catch (e) {}
1388
- }
1389
- chip.remove();
1390
- });
1391
- chip.appendChild(dismiss);
1392
- }
1453
+ var dismiss = document.createElement('span');
1454
+ dismiss.className = 'chip-dismiss';
1455
+ dismiss.textContent = '\u00d7';
1456
+ dismiss.addEventListener('click', function () {
1457
+ if (interaction.revertable && handle && handle.revertInteraction) {
1458
+ try {
1459
+ handle.revertInteraction(interaction);
1460
+ } catch (e) {}
1461
+ }
1462
+ chip.remove();
1463
+ });
1464
+ chip.appendChild(dismiss);
1393
1465
 
1394
1466
  tray.appendChild(chip);
1395
1467
  }
@@ -1435,17 +1507,30 @@
1435
1507
  var chip = document.createElement('span');
1436
1508
  chip.className = 'cumulus-annotation-chip';
1437
1509
  chip.setAttribute('data-serialized', serialized);
1510
+ chip.setAttribute('data-quote', quote);
1511
+ chip.setAttribute('data-comment', comment);
1512
+ chip.setAttribute('data-is-code', isCodeBlock ? '1' : '');
1513
+ chip.setAttribute('data-code-lang', codeLang || '');
1438
1514
  chip.title = '"' + quote + '"\n\n' + comment;
1439
1515
 
1440
1516
  var label = document.createElement('span');
1517
+ label.className = 'chip-label';
1441
1518
  label.textContent =
1442
1519
  '\uD83D\uDCDD \u201C' + truncatedQuote + '\u201D \u2192 ' + truncatedComment;
1443
1520
  chip.appendChild(label);
1444
1521
 
1522
+ // Click label to edit
1523
+ label.style.cursor = 'pointer';
1524
+ label.addEventListener('click', function (e) {
1525
+ e.stopPropagation();
1526
+ showAnnotationEditPopover(panel, chip);
1527
+ });
1528
+
1445
1529
  var dismiss = document.createElement('span');
1446
1530
  dismiss.className = 'chip-dismiss';
1447
1531
  dismiss.textContent = '\u00d7';
1448
- dismiss.addEventListener('click', function () {
1532
+ dismiss.addEventListener('click', function (e) {
1533
+ e.stopPropagation();
1449
1534
  chip.remove();
1450
1535
  });
1451
1536
  chip.appendChild(dismiss);
@@ -1453,6 +1538,104 @@
1453
1538
  tray.appendChild(chip);
1454
1539
  }
1455
1540
 
1541
+ // Show the annotation popover near a chip for editing
1542
+ function showAnnotationEditPopover(panel, chip) {
1543
+ dismissAnnotationPopover(panel);
1544
+
1545
+ var quote = chip.getAttribute('data-quote') || '';
1546
+ var comment = chip.getAttribute('data-comment') || '';
1547
+ var isCodeBlock = chip.getAttribute('data-is-code') === '1';
1548
+ var codeLang = chip.getAttribute('data-code-lang') || '';
1549
+
1550
+ var popover = document.createElement('div');
1551
+ popover.className = 'cumulus-annotation-popover';
1552
+
1553
+ // Position near the chip
1554
+ var panelRect = panel.getBoundingClientRect();
1555
+ var chipRect = chip.getBoundingClientRect();
1556
+ var top = chipRect.top - panelRect.top + panel.scrollTop - 160;
1557
+ if (top < 4) top = chipRect.bottom - panelRect.top + panel.scrollTop + 4;
1558
+ var left = chipRect.left - panelRect.left;
1559
+ if (left + 260 > panelRect.width) left = panelRect.width - 270;
1560
+ if (left < 4) left = 4;
1561
+ popover.style.top = top + 'px';
1562
+ popover.style.left = left + 'px';
1563
+
1564
+ var quoteEl = document.createElement('div');
1565
+ quoteEl.className = 'annotation-quote';
1566
+ quoteEl.textContent = '\u201C' + quote + '\u201D';
1567
+ popover.appendChild(quoteEl);
1568
+
1569
+ var textarea = document.createElement('textarea');
1570
+ textarea.placeholder = 'Edit comment\u2026';
1571
+ textarea.rows = 4;
1572
+ textarea.value = comment;
1573
+ textarea.setAttribute('data-testid', 'annotation-edit-comment');
1574
+ popover.appendChild(textarea);
1575
+
1576
+ var actions = document.createElement('div');
1577
+ actions.className = 'annotation-actions';
1578
+
1579
+ var cancelBtn = document.createElement('button');
1580
+ cancelBtn.className = 'annotation-cancel';
1581
+ cancelBtn.textContent = 'Cancel';
1582
+ cancelBtn.addEventListener('click', function () {
1583
+ dismissAnnotationPopover(panel);
1584
+ });
1585
+
1586
+ var submitBtn = document.createElement('button');
1587
+ submitBtn.className = 'annotation-submit';
1588
+ submitBtn.textContent = 'Update';
1589
+ submitBtn.setAttribute('data-testid', 'annotation-edit-submit');
1590
+ submitBtn.addEventListener('click', function () {
1591
+ var newComment = textarea.value.trim();
1592
+ if (!newComment) {
1593
+ dismissAnnotationPopover(panel);
1594
+ return;
1595
+ }
1596
+ // Update chip data
1597
+ var newSerialized;
1598
+ if (isCodeBlock) {
1599
+ newSerialized =
1600
+ '```' +
1601
+ (codeLang && codeLang !== 'text' ? codeLang : '') +
1602
+ '\n' +
1603
+ quote +
1604
+ '\n```\n' +
1605
+ newComment;
1606
+ } else {
1607
+ newSerialized = '> ' + quote.replace(/\n/g, '\n> ') + '\n' + newComment;
1608
+ }
1609
+ chip.setAttribute('data-serialized', newSerialized);
1610
+ chip.setAttribute('data-comment', newComment);
1611
+ var truncQ = quote.length > 30 ? quote.substring(0, 30) + '\u2026' : quote;
1612
+ var truncC = newComment.length > 30 ? newComment.substring(0, 30) + '\u2026' : newComment;
1613
+ var labelEl = chip.querySelector('.chip-label');
1614
+ if (labelEl) {
1615
+ labelEl.textContent = '\uD83D\uDCDD \u201C' + truncQ + '\u201D \u2192 ' + truncC;
1616
+ }
1617
+ chip.title = '"' + quote + '"\n\n' + newComment;
1618
+ dismissAnnotationPopover(panel);
1619
+ });
1620
+
1621
+ actions.appendChild(cancelBtn);
1622
+ actions.appendChild(submitBtn);
1623
+ popover.appendChild(actions);
1624
+
1625
+ textarea.addEventListener('keydown', function (e) {
1626
+ if (e.key === 'Enter' && !e.shiftKey) {
1627
+ e.preventDefault();
1628
+ submitBtn.click();
1629
+ } else if (e.key === 'Escape') {
1630
+ dismissAnnotationPopover(panel);
1631
+ }
1632
+ });
1633
+
1634
+ panel.appendChild(popover);
1635
+ textarea.focus();
1636
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
1637
+ }
1638
+
1456
1639
  // Show the annotation popover near a text selection
1457
1640
  function showAnnotationPopover(panel, selectedText, anchorRect, isCodeBlock, codeLang) {
1458
1641
  // Remove any existing popover
@@ -1597,7 +1780,7 @@
1597
1780
  var langLabel = lang.trim() || 'text';
1598
1781
  var escapedCode = escapeHtml(code.replace(/\n$/, '')); // trim trailing newline
1599
1782
  var tokenId = 'cb' + idx;
1600
- codeBlocks.push(buildCodeBlock(langLabel, escapedCode, tokenId));
1783
+ codeBlocks.push(buildCodeBlock(langLabel, escapedCode, tokenId, code));
1601
1784
  return TOKEN_PREFIX + idx + '\x00';
1602
1785
  });
1603
1786
 
@@ -1671,16 +1854,36 @@
1671
1854
  return { html: html, blexBlocks: blexBlocks };
1672
1855
  }
1673
1856
 
1674
- function buildCodeBlock(lang, escapedCode, tokenId) {
1857
+ function looksLikeUnicodeArt(text) {
1858
+ var boxChars = /[┌┐└┘├┤┬┴┼│─╔╗╚╝╠╣╦╩╬║═┏┓┗┛┣┫┳┻╋┃━╭╮╰╯░▒▓█▶◀▲▼→←↑↓]/;
1859
+ var lines = text.split('\n');
1860
+ var hitCount = 0;
1861
+ for (var i = 0; i < lines.length; i++) {
1862
+ if (boxChars.test(lines[i])) hitCount++;
1863
+ }
1864
+ return hitCount >= 3;
1865
+ }
1866
+
1867
+ function buildCodeBlock(lang, escapedCode, tokenId, rawCode) {
1868
+ var editBtn = '';
1869
+ if (rawCode && looksLikeUnicodeArt(rawCode)) {
1870
+ editBtn =
1871
+ '<button class="code-block-edit-btn" data-edit-target="' +
1872
+ tokenId +
1873
+ '" data-testid="webchat-edit-code">Edit ✎</button>';
1874
+ }
1675
1875
  return (
1676
1876
  '<div class="code-block-wrapper">' +
1677
1877
  '<div class="code-block-header">' +
1678
1878
  '<span class="code-block-language">' +
1679
1879
  escapeHtml(lang) +
1680
1880
  '</span>' +
1881
+ '<span style="display:flex;gap:6px;">' +
1882
+ editBtn +
1681
1883
  '<button class="code-block-copy-btn" data-copy-target="' +
1682
1884
  tokenId +
1683
1885
  '" data-testid="webchat-copy-code">Copy</button>' +
1886
+ '</span>' +
1684
1887
  '</div>' +
1685
1888
  '<pre><code data-code-id="' +
1686
1889
  tokenId +
@@ -1691,6 +1894,146 @@
1691
1894
  );
1692
1895
  }
1693
1896
 
1897
+ // Texitool embed URL — change this if self-hosting
1898
+ var TEXITOOL_EMBED_URL = 'https://texi.soapko.com';
1899
+
1900
+ function openTexitoolEditor(wrapper) {
1901
+ var codeEl = wrapper.querySelector('code');
1902
+ if (!codeEl) return;
1903
+ var rawText = codeEl.textContent || '';
1904
+
1905
+ // Build iframe container
1906
+ var container = document.createElement('div');
1907
+ container.className = 'texitool-embed-container';
1908
+
1909
+ var iframe = document.createElement('iframe');
1910
+ var encodedContent = btoa(unescape(encodeURIComponent(rawText)));
1911
+ iframe.src = TEXITOOL_EMBED_URL + '?embed=true&content=' + encodedContent;
1912
+ iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
1913
+ iframe.setAttribute('data-testid', 'texitool-embed');
1914
+ container.appendChild(iframe);
1915
+
1916
+ // Action bar
1917
+ var actions = document.createElement('div');
1918
+ actions.className = 'texitool-embed-actions';
1919
+ var saveBtn = document.createElement('button');
1920
+ saveBtn.textContent = 'Save';
1921
+ saveBtn.setAttribute('data-testid', 'texitool-save');
1922
+ var cancelBtn = document.createElement('button');
1923
+ cancelBtn.textContent = 'Cancel';
1924
+ cancelBtn.setAttribute('data-testid', 'texitool-cancel');
1925
+ actions.appendChild(saveBtn);
1926
+ actions.appendChild(cancelBtn);
1927
+ container.appendChild(actions);
1928
+
1929
+ // Store original wrapper for restore
1930
+ var parent = wrapper.parentNode;
1931
+ parent.replaceChild(container, wrapper);
1932
+
1933
+ // Listen for postMessage from Texitool
1934
+ function onMessage(e) {
1935
+ if (!e.data || typeof e.data !== 'object') return;
1936
+ if (e.data.type === 'texitool:save' && e.data.content) {
1937
+ finishEdit(e.data.content);
1938
+ } else if (e.data.type === 'texitool:cancel') {
1939
+ cancelEdit();
1940
+ }
1941
+ }
1942
+ window.addEventListener('message', onMessage);
1943
+
1944
+ function finishEdit(newContent) {
1945
+ window.removeEventListener('message', onMessage);
1946
+ // Rebuild the code block with updated content
1947
+ var newWrapper = document.createElement('div');
1948
+ newWrapper.innerHTML = buildCodeBlock(
1949
+ 'text',
1950
+ escapeHtml(newContent),
1951
+ 'edited-' + Date.now(),
1952
+ newContent
1953
+ );
1954
+ var built = newWrapper.firstChild;
1955
+ parent.replaceChild(built, container);
1956
+ wireEditButtons(built);
1957
+ wireCopyButtons(built);
1958
+ // Chip the edited content to the input tray
1959
+ chipEditedContent(newContent, parent);
1960
+ }
1961
+
1962
+ function cancelEdit() {
1963
+ window.removeEventListener('message', onMessage);
1964
+ parent.replaceChild(wrapper, container);
1965
+ }
1966
+
1967
+ // Wire save/cancel buttons as fallback (in case postMessage isn't available)
1968
+ saveBtn.addEventListener('click', function () {
1969
+ // Try to get content from iframe via postMessage request
1970
+ iframe.contentWindow.postMessage({ type: 'texitool:requestSave' }, '*');
1971
+ // Fallback: if no response in 500ms, use original content
1972
+ setTimeout(function () {
1973
+ if (container.parentNode) cancelEdit(); // still mounted = no response
1974
+ }, 500);
1975
+ });
1976
+
1977
+ cancelBtn.addEventListener('click', cancelEdit);
1978
+ }
1979
+
1980
+ function chipEditedContent(content, contextEl) {
1981
+ // Find the nearest thread panel and chip tray
1982
+ var panel = contextEl.closest('.cumulus-thread-panel');
1983
+ if (!panel) panel = document.querySelector('.cumulus-thread-panel');
1984
+ if (!panel) return;
1985
+ var tray = panel.querySelector('.cumulus-chip-tray');
1986
+ if (!tray) return;
1987
+ // Add as an annotation chip with the edited diagram
1988
+ var chip = document.createElement('span');
1989
+ chip.className = 'cumulus-chip cumulus-annotation-chip';
1990
+ chip.style.cssText = 'display:inline-flex;align-items:center;gap:4px;max-width:200px;';
1991
+ var label = document.createElement('span');
1992
+ label.className = 'chip-label';
1993
+ label.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
1994
+ label.textContent = '✎ edited diagram';
1995
+ chip.appendChild(label);
1996
+ var dismiss = document.createElement('span');
1997
+ dismiss.textContent = '×';
1998
+ dismiss.className = 'chip-dismiss';
1999
+ dismiss.style.cssText = 'cursor:pointer;opacity:0.6;flex-shrink:0;';
2000
+ dismiss.addEventListener('click', function () {
2001
+ chip.remove();
2002
+ });
2003
+ chip.appendChild(dismiss);
2004
+ // Store the content on the chip for collectAndClearChips
2005
+ chip.setAttribute('data-annotation', '```\n' + content + '\n```\nEdited diagram');
2006
+ tray.appendChild(chip);
2007
+ tray.style.display = 'flex';
2008
+ }
2009
+
2010
+ function wireEditButtons(el) {
2011
+ el.querySelectorAll('.code-block-edit-btn').forEach(function (btn) {
2012
+ btn.addEventListener('click', function () {
2013
+ var wrapper = btn.closest('.code-block-wrapper');
2014
+ if (wrapper) openTexitoolEditor(wrapper);
2015
+ });
2016
+ });
2017
+ }
2018
+
2019
+ function wireCopyButtons(el) {
2020
+ el.querySelectorAll('.code-block-copy-btn').forEach(function (btn) {
2021
+ btn.addEventListener('click', function () {
2022
+ var targetId = btn.getAttribute('data-copy-target');
2023
+ var codeEl = el.querySelector('[data-code-id="' + targetId + '"]');
2024
+ if (!codeEl) codeEl = btn.closest('.code-block-wrapper').querySelector('code');
2025
+ if (codeEl && navigator.clipboard) {
2026
+ navigator.clipboard.writeText(codeEl.textContent || '').then(function () {
2027
+ btn.textContent = 'Copied!';
2028
+ setTimeout(function () {
2029
+ btn.textContent = 'Copy';
2030
+ }, 2000);
2031
+ });
2032
+ }
2033
+ });
2034
+ });
2035
+ }
2036
+
1694
2037
  function renderTables(html) {
1695
2038
  // Split on double newlines to find table blocks
1696
2039
  // A table block: lines where most lines start with |
@@ -2254,20 +2597,8 @@
2254
2597
  if (!isStreaming) {
2255
2598
  renderBlexBlocks(el, mdResult.blexBlocks, false, msgKey, threadName);
2256
2599
  }
2257
- el.querySelectorAll('.code-block-copy-btn').forEach(function (btn) {
2258
- btn.addEventListener('click', function () {
2259
- var targetId = btn.getAttribute('data-copy-target');
2260
- var codeEl = el.querySelector('[data-code-id="' + targetId + '"]');
2261
- if (codeEl && navigator.clipboard) {
2262
- navigator.clipboard.writeText(codeEl.textContent || '').then(function () {
2263
- btn.textContent = 'Copied!';
2264
- setTimeout(function () {
2265
- btn.textContent = 'Copy';
2266
- }, 2000);
2267
- });
2268
- }
2269
- });
2270
- });
2600
+ wireCopyButtons(el);
2601
+ wireEditButtons(el);
2271
2602
  } else if (isStreaming) {
2272
2603
  el.innerHTML =
2273
2604
  '<span class="cumulus-typing-dots"><span></span><span></span><span></span></span>';
@@ -3820,20 +4151,8 @@
3820
4151
  if (!isStreaming) {
3821
4152
  renderBlexBlocks(el, mdResult.blexBlocks, false, msgKey, threadName);
3822
4153
  }
3823
- el.querySelectorAll('.code-block-copy-btn').forEach(function (btn) {
3824
- btn.addEventListener('click', function () {
3825
- var targetId = btn.getAttribute('data-copy-target');
3826
- var codeEl = el.querySelector('[data-code-id="' + targetId + '"]');
3827
- if (codeEl && navigator.clipboard) {
3828
- navigator.clipboard.writeText(codeEl.textContent || '').then(function () {
3829
- btn.textContent = 'Copied!';
3830
- setTimeout(function () {
3831
- btn.textContent = 'Copy';
3832
- }, 2000);
3833
- });
3834
- }
3835
- });
3836
- });
4154
+ wireCopyButtons(el);
4155
+ wireEditButtons(el);
3837
4156
  } else if (isStreaming) {
3838
4157
  el.innerHTML =
3839
4158
  '<span class="cumulus-typing-dots"><span></span><span></span><span></span></span>';
@@ -4587,55 +4906,72 @@
4587
4906
  // ── Push notification subscription ──
4588
4907
  function registerPushNotifications() {
4589
4908
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
4590
- console.log('[Cumulus] Push notifications not supported');
4909
+ console.log('[Cumulus] Push notifications not supported in this browser');
4591
4910
  return;
4592
4911
  }
4593
4912
 
4594
- navigator.serviceWorker
4595
- .register('/sw.js')
4596
- .then(function (registration) {
4597
- console.log('[Cumulus] Service worker registered');
4598
-
4599
- // Check existing subscription
4600
- return registration.pushManager.getSubscription().then(function (existing) {
4601
- if (existing) {
4602
- // Already subscribed — send to server in case it's new/different
4603
- sendPushSubscription(existing);
4604
- return;
4605
- }
4913
+ if (!('Notification' in window)) {
4914
+ console.log('[Cumulus] Notification API not available');
4915
+ return;
4916
+ }
4606
4917
 
4607
- // Get VAPID key from server
4608
- var loc = window.location;
4609
- var apiUrl = loc.protocol + '//' + loc.host;
4610
- fetch(apiUrl + '/api/push/vapid-key')
4611
- .then(function (r) {
4612
- return r.json();
4613
- })
4614
- .then(function (data) {
4615
- if (!data.publicKey) {
4616
- console.log('[Cumulus] Push not configured on server');
4918
+ // Must request permission explicitly (required for iOS PWA)
4919
+ Notification.requestPermission().then(function (permission) {
4920
+ console.log('[Cumulus] Notification permission:', permission);
4921
+ if (permission !== 'granted') {
4922
+ console.log('[Cumulus] Push notifications denied by user');
4923
+ return;
4924
+ }
4925
+
4926
+ navigator.serviceWorker
4927
+ .register('/sw.js', { scope: '/' })
4928
+ .then(function (registration) {
4929
+ console.log('[Cumulus] Service worker registered, scope:', registration.scope);
4930
+
4931
+ // Wait for the service worker to be ready
4932
+ return navigator.serviceWorker.ready.then(function (reg) {
4933
+ // Check existing subscription
4934
+ return reg.pushManager.getSubscription().then(function (existing) {
4935
+ if (existing) {
4936
+ console.log('[Cumulus] Existing push subscription found');
4937
+ sendPushSubscription(existing);
4617
4938
  return;
4618
4939
  }
4619
4940
 
4620
- // Request permission and subscribe
4621
- return registration.pushManager
4622
- .subscribe({
4623
- userVisibleOnly: true,
4624
- applicationServerKey: urlBase64ToUint8Array(data.publicKey),
4941
+ // Get VAPID key from server
4942
+ var loc = window.location;
4943
+ var apiUrl = loc.protocol + '//' + loc.host;
4944
+ fetch(apiUrl + '/api/push/vapid-key')
4945
+ .then(function (r) {
4946
+ return r.json();
4947
+ })
4948
+ .then(function (data) {
4949
+ if (!data.publicKey) {
4950
+ console.log('[Cumulus] Push not configured on server (no VAPID key)');
4951
+ return;
4952
+ }
4953
+
4954
+ console.log('[Cumulus] Subscribing to push with VAPID key');
4955
+ return reg.pushManager
4956
+ .subscribe({
4957
+ userVisibleOnly: true,
4958
+ applicationServerKey: urlBase64ToUint8Array(data.publicKey),
4959
+ })
4960
+ .then(function (subscription) {
4961
+ console.log('[Cumulus] Push subscription created:', subscription.endpoint);
4962
+ sendPushSubscription(subscription);
4963
+ });
4625
4964
  })
4626
- .then(function (subscription) {
4627
- console.log('[Cumulus] Push subscription created');
4628
- sendPushSubscription(subscription);
4965
+ .catch(function (err) {
4966
+ console.warn('[Cumulus] Push subscription failed:', err.message || err);
4629
4967
  });
4630
- })
4631
- .catch(function (err) {
4632
- console.warn('[Cumulus] Push subscription failed:', err);
4633
4968
  });
4969
+ });
4970
+ })
4971
+ .catch(function (err) {
4972
+ console.warn('[Cumulus] Service worker registration failed:', err.message || err);
4634
4973
  });
4635
- })
4636
- .catch(function (err) {
4637
- console.warn('[Cumulus] Service worker registration failed:', err);
4638
- });
4974
+ });
4639
4975
  }
4640
4976
 
4641
4977
  function sendPushSubscription(subscription) {