@luckydraw/cumulus 0.27.40 → 0.28.0

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.
Files changed (99) hide show
  1. package/dist/gateway/adapters/webchat.d.ts +5 -7
  2. package/dist/gateway/adapters/webchat.d.ts.map +1 -1
  3. package/dist/gateway/adapters/webchat.js +204 -57
  4. package/dist/gateway/adapters/webchat.js.map +1 -1
  5. package/dist/gateway/config.d.ts +48 -0
  6. package/dist/gateway/config.d.ts.map +1 -1
  7. package/dist/gateway/config.js +90 -0
  8. package/dist/gateway/config.js.map +1 -1
  9. package/dist/gateway/daemon.d.ts.map +1 -1
  10. package/dist/gateway/daemon.js +268 -27
  11. package/dist/gateway/daemon.js.map +1 -1
  12. package/dist/gateway/gateway-agents-mcp.js +570 -31
  13. package/dist/gateway/gateway-agents-mcp.js.map +1 -1
  14. package/dist/gateway/hooks.d.ts +101 -0
  15. package/dist/gateway/hooks.d.ts.map +1 -0
  16. package/dist/gateway/hooks.js +200 -0
  17. package/dist/gateway/hooks.js.map +1 -0
  18. package/dist/gateway/scheduler.d.ts +50 -0
  19. package/dist/gateway/scheduler.d.ts.map +1 -0
  20. package/dist/gateway/scheduler.js +263 -0
  21. package/dist/gateway/scheduler.js.map +1 -0
  22. package/dist/gateway/server.d.ts +16 -1
  23. package/dist/gateway/server.d.ts.map +1 -1
  24. package/dist/gateway/server.js +451 -22
  25. package/dist/gateway/server.js.map +1 -1
  26. package/dist/gateway/static/blex.min.js +74 -17
  27. package/dist/gateway/static/chat.html +1 -4
  28. package/dist/gateway/static/dashboard.html +151 -0
  29. package/dist/gateway/static/dashboard.js +229 -0
  30. package/dist/gateway/static/widget.js +656 -460
  31. package/dist/index.d.ts +13 -5
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +9 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/agentic-loop.d.ts +124 -0
  36. package/dist/lib/agentic-loop.d.ts.map +1 -0
  37. package/dist/lib/agentic-loop.js +338 -0
  38. package/dist/lib/agentic-loop.js.map +1 -0
  39. package/dist/lib/config.d.ts +28 -15
  40. package/dist/lib/config.d.ts.map +1 -1
  41. package/dist/lib/config.js +47 -41
  42. package/dist/lib/config.js.map +1 -1
  43. package/dist/lib/gateway.d.ts +34 -3
  44. package/dist/lib/gateway.d.ts.map +1 -1
  45. package/dist/lib/gateway.js +584 -37
  46. package/dist/lib/gateway.js.map +1 -1
  47. package/dist/lib/huggingface-provider.d.ts +26 -0
  48. package/dist/lib/huggingface-provider.d.ts.map +1 -0
  49. package/dist/lib/huggingface-provider.js +338 -0
  50. package/dist/lib/huggingface-provider.js.map +1 -0
  51. package/dist/lib/provider.d.ts +71 -0
  52. package/dist/lib/provider.d.ts.map +1 -0
  53. package/dist/lib/provider.js +90 -0
  54. package/dist/lib/provider.js.map +1 -0
  55. package/dist/lib/tools/ask-user-question.d.ts +14 -0
  56. package/dist/lib/tools/ask-user-question.d.ts.map +1 -0
  57. package/dist/lib/tools/ask-user-question.js +74 -0
  58. package/dist/lib/tools/ask-user-question.js.map +1 -0
  59. package/dist/lib/tools/bash.d.ts +6 -0
  60. package/dist/lib/tools/bash.d.ts.map +1 -0
  61. package/dist/lib/tools/bash.js +85 -0
  62. package/dist/lib/tools/bash.js.map +1 -0
  63. package/dist/lib/tools/edit.d.ts +6 -0
  64. package/dist/lib/tools/edit.d.ts.map +1 -0
  65. package/dist/lib/tools/edit.js +79 -0
  66. package/dist/lib/tools/edit.js.map +1 -0
  67. package/dist/lib/tools/glob.d.ts +6 -0
  68. package/dist/lib/tools/glob.d.ts.map +1 -0
  69. package/dist/lib/tools/glob.js +91 -0
  70. package/dist/lib/tools/glob.js.map +1 -0
  71. package/dist/lib/tools/grep.d.ts +6 -0
  72. package/dist/lib/tools/grep.d.ts.map +1 -0
  73. package/dist/lib/tools/grep.js +110 -0
  74. package/dist/lib/tools/grep.js.map +1 -0
  75. package/dist/lib/tools/index.d.ts +29 -0
  76. package/dist/lib/tools/index.d.ts.map +1 -0
  77. package/dist/lib/tools/index.js +52 -0
  78. package/dist/lib/tools/index.js.map +1 -0
  79. package/dist/lib/tools/read.d.ts +6 -0
  80. package/dist/lib/tools/read.d.ts.map +1 -0
  81. package/dist/lib/tools/read.js +80 -0
  82. package/dist/lib/tools/read.js.map +1 -0
  83. package/dist/lib/tools/tool-search.d.ts +30 -0
  84. package/dist/lib/tools/tool-search.d.ts.map +1 -0
  85. package/dist/lib/tools/tool-search.js +108 -0
  86. package/dist/lib/tools/tool-search.js.map +1 -0
  87. package/dist/lib/tools/web-fetch.d.ts +9 -0
  88. package/dist/lib/tools/web-fetch.d.ts.map +1 -0
  89. package/dist/lib/tools/web-fetch.js +114 -0
  90. package/dist/lib/tools/web-fetch.js.map +1 -0
  91. package/dist/lib/tools/web-search.d.ts +12 -0
  92. package/dist/lib/tools/web-search.d.ts.map +1 -0
  93. package/dist/lib/tools/web-search.js +89 -0
  94. package/dist/lib/tools/web-search.js.map +1 -0
  95. package/dist/lib/tools/write.d.ts +6 -0
  96. package/dist/lib/tools/write.d.ts.map +1 -0
  97. package/dist/lib/tools/write.js +44 -0
  98. package/dist/lib/tools/write.js.map +1 -0
  99. package/package.json +3 -2
@@ -44,14 +44,7 @@
44
44
 
45
45
  // ─── Styles ─────────────────────────────────────────────────────────────────
46
46
  var STYLES = [
47
- /* box-sizing reset exclude blex block internals so blex CSS isn't overridden */
48
- '.cumulus-widget *:not(.blex-block-container *), .cumulus-standalone-root *:not(.blex-block-container *) { box-sizing: border-box; }',
49
- /* Zero-specificity reset: any class selector (widget or blex) overrides this naturally */
50
- ':where(.cumulus-widget, .cumulus-standalone-root) :where(*:not(.blex-block-container *)) { margin: 0; padding: 0; }',
51
- /* Blex container: isolate from widget styles, let blex own its internals */
52
- '.blex-block-container { isolation: isolate; }',
53
- /* Restore browser defaults for blex internals that the widget reset may have affected */
54
- '.blex-block-container * { box-sizing: content-box; }',
47
+ '.cumulus-widget * { box-sizing: border-box; margin: 0; padding: 0; }',
55
48
 
56
49
  '.cumulus-widget {',
57
50
  ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;',
@@ -102,11 +95,15 @@
102
95
  ' flex-shrink: 0;',
103
96
  '}',
104
97
  '.cumulus-header-title { font-weight: 600; font-size: 15px; color: #e0e0e0; }',
105
- '.cumulus-header-status { font-size: 11px; display: flex; align-items: center; gap: 6px; color: #aaa; }',
106
- '.cumulus-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #555; }',
98
+ '.cumulus-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #555; transition: background 0.3s; }',
107
99
  '.cumulus-status-dot.connected { background: #22c55e; }',
108
- '.cumulus-status-dot.connecting { background: #f59e0b; }',
100
+ '.cumulus-status-dot.connecting { background: #f59e0b; animation: cumulus-pulse 1s ease-in-out infinite; }',
109
101
  '.cumulus-status-dot.disconnected { background: #ef4444; }',
102
+ '.cumulus-status-dot.stale { background: #f59e0b; }',
103
+ '@keyframes cumulus-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }',
104
+ '.cumulus-header-status { font-size: 11px; display: flex; align-items: center; gap: 6px; color: #aaa; cursor: pointer; user-select: none; }',
105
+ '.cumulus-header-status:hover { color: #e0e0e0; }',
106
+ '.cumulus-header-status:active { opacity: 0.7; }',
110
107
  '.cumulus-close-btn { background: none; border: none; color: #888; cursor: pointer; font-size: 20px; padding: 2px 4px; line-height: 1; }',
111
108
  '.cumulus-close-btn:hover { color: #e0e0e0; }',
112
109
 
@@ -124,8 +121,8 @@
124
121
  /* ── Message inner container (max-width centering) ── */
125
122
  '.cumulus-msg-row {',
126
123
  ' display: flex; flex-direction: column;',
127
- ' width: 100%;',
128
- ' padding-left: 1.5em;',
124
+ ' max-width: 78ch; width: 100%;',
125
+ ' margin: 0 auto;',
129
126
  '}',
130
127
 
131
128
  /* ── User messages ── */
@@ -140,7 +137,21 @@
140
137
  ' word-wrap: break-word;',
141
138
  ' white-space: pre-wrap;',
142
139
  '}',
143
- '.cumulus-msg.user.wide { max-width: 78ch; }',
140
+ '.cumulus-msg.user.wide { max-width: 100%; }',
141
+
142
+ /* ── System messages (e.g. "Gateway restarted") ── */
143
+ '.cumulus-msg.system {',
144
+ ' align-self: center;',
145
+ ' background: transparent;',
146
+ ' color: #8a9aaa;',
147
+ ' border: 1px dashed #3a4a5a;',
148
+ ' border-radius: 0.5em;',
149
+ ' padding: 0.35em 0.8em;',
150
+ ' max-width: 90%;',
151
+ ' font-size: 0.82em;',
152
+ ' text-align: center;',
153
+ ' font-style: italic;',
154
+ '}',
144
155
 
145
156
  /* ── User message attachments ── */
146
157
  '.cumulus-msg-attachments { display: flex; flex-wrap: wrap; gap: 0.4em; margin-top: 0.4em; }',
@@ -166,7 +177,6 @@
166
177
  '.cumulus-msg-time {',
167
178
  ' font-size: 0.75em; color: #555; margin-top: 0.3em; padding: 0 0.3em;',
168
179
  '}',
169
- '.cumulus-msg-row:has(.user) { max-width: 78ch; }',
170
180
  '.cumulus-msg-row:has(.user) .cumulus-msg-time { align-self: flex-end; }',
171
181
  '.cumulus-msg-row:has(.assistant) .cumulus-msg-time { align-self: flex-start; }',
172
182
 
@@ -200,26 +210,42 @@
200
210
  '.cumulus-msg.assistant p:first-child { margin-top: 0; }',
201
211
  '.cumulus-msg.assistant p:last-child { margin-bottom: 0; }',
202
212
 
203
- '.cumulus-msg a { color: #4da6ff; text-decoration: none; }',
204
- '.cumulus-msg a:hover { text-decoration: underline; }',
205
- '.cumulus-msg strong { font-weight: 600; }',
206
- '.cumulus-msg em { font-style: italic; }',
207
- '.cumulus-msg del { text-decoration: line-through; color: #888; }',
208
-
209
- /* Constrain text inside assistant messages, but let blex blocks go full width */
210
- '.cumulus-msg.assistant > * { max-width: 78ch; }',
211
- '.cumulus-msg.assistant > .blex-block-container { max-width: none; width: 100%; }',
212
-
213
213
  /* Blex block containers */
214
214
  '.blex-block-container {',
215
215
  ' margin: 0.6em 0;',
216
216
  ' border-radius: 8px;',
217
- ' overflow: visible;',
217
+ ' overflow: hidden;',
218
218
  ' min-height: 2em;',
219
219
  '}',
220
220
  '.blex-block-container[data-blex-ready="true"] {',
221
221
  ' min-height: auto;',
222
222
  '}',
223
+ /* Blex CSS isolation — revert widget resets inside blex blocks */
224
+ '.blex-block-container * { margin: revert; padding: revert; line-height: revert; list-style: revert; border-spacing: revert; }',
225
+ '.blex-block-container { isolation: isolate; }',
226
+
227
+ /* Chip tray for deferred blex interactions */
228
+ '.cumulus-chip-tray {',
229
+ ' display: flex; flex-direction: row; flex-wrap: wrap;',
230
+ ' gap: 0.35em; max-width: 78ch; align-self: center; width: 100%;',
231
+ ' padding-bottom: 0.15em;',
232
+ '}',
233
+ '.cumulus-interaction-chip {',
234
+ ' display: inline-flex; align-items: center; gap: 0.3em;',
235
+ ' background: #2a3a4a; color: #d4e8f8; border: 1px solid #3a5060;',
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;',
238
+ '}',
239
+ '.cumulus-interaction-chip .chip-dismiss {',
240
+ ' cursor: pointer; opacity: 0.6; font-size: 0.9em; margin-left: 0.2em;',
241
+ '}',
242
+ '.cumulus-interaction-chip .chip-dismiss:hover { opacity: 1; }',
243
+
244
+ '.cumulus-msg a { color: #4da6ff; text-decoration: none; }',
245
+ '.cumulus-msg a:hover { text-decoration: underline; }',
246
+ '.cumulus-msg strong { font-weight: 600; }',
247
+ '.cumulus-msg em { font-style: italic; }',
248
+ '.cumulus-msg del { text-decoration: line-through; color: #888; }',
223
249
 
224
250
  /* Headings */
225
251
  '.cumulus-msg h1,.cumulus-msg h2,.cumulus-msg h3,.cumulus-msg h4,.cumulus-msg h5,.cumulus-msg h6 {',
@@ -365,6 +391,12 @@
365
391
  ' text-align: center; padding: 20px;',
366
392
  '}',
367
393
 
394
+ /* ── Load older messages indicator ── */
395
+ '.cumulus-load-older {',
396
+ ' text-align: center; padding: 10px; color: #666;',
397
+ ' font-size: 12px; font-style: italic;',
398
+ '}',
399
+
368
400
  /* ── Input area ── */
369
401
  '.cumulus-input-area {',
370
402
  ' display: flex; flex-direction: column;',
@@ -379,8 +411,8 @@
379
411
  '.cumulus-attach-strip {',
380
412
  ' display: flex; flex-direction: row;',
381
413
  ' gap: 0.5em; overflow-x: auto;',
382
- ' max-width: 78ch; width: 100%;',
383
- ' padding-bottom: 0.1em; padding-left: 1.5em;',
414
+ ' max-width: 78ch; align-self: center; width: 100%;',
415
+ ' padding-bottom: 0.1em;',
384
416
  '}',
385
417
  '.cumulus-attach-strip::-webkit-scrollbar { height: 4px; }',
386
418
  '.cumulus-attach-strip::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; }',
@@ -416,37 +448,11 @@
416
448
  '}',
417
449
  '.cumulus-attach-chip-remove:hover { color: #ff6666; }',
418
450
 
419
- /* Interaction chip tray (blex deferred interactions) */
420
- '.cumulus-chip-tray {',
421
- ' display: flex; flex-direction: row; flex-wrap: wrap;',
422
- ' gap: 0.35em; max-width: 78ch; width: 100%;',
423
- ' padding-bottom: 0.15em; padding-left: 1.5em;',
424
- '}',
425
- '.cumulus-chip-tray:empty { display: none; }',
426
- '.cumulus-interaction-chip {',
427
- ' display: inline-flex; align-items: center; gap: 0.3em;',
428
- ' background: #2a3a4a; border: 1px solid #3a5a7a;',
429
- ' border-radius: 1em; padding: 0.2em 0.55em;',
430
- ' font-size: 0.8em; color: #bcd; cursor: default;',
431
- ' max-width: 20em; overflow: hidden; white-space: nowrap;',
432
- '}',
433
- '.cumulus-interaction-chip-icon { flex-shrink: 0; }',
434
- '.cumulus-interaction-chip-label { overflow: hidden; text-overflow: ellipsis; }',
435
- '.cumulus-interaction-chip-dismiss {',
436
- ' flex-shrink: 0; background: none; border: none;',
437
- ' color: #789; font-size: 0.9em; cursor: pointer;',
438
- ' padding: 0 0.1em; line-height: 1;',
439
- '}',
440
- '.cumulus-interaction-chip-dismiss:hover { color: #ff6666; }',
441
- '@keyframes chipFlash { 0% { background: #3a5a7a; } 100% { background: #2a3a4a; } }',
442
- '.cumulus-interaction-chip--immediate { animation: chipFlash 0.15s ease-out; }',
443
-
444
451
  /* Input row (attach btn + textarea + send btn) */
445
452
  '.cumulus-input-row {',
446
453
  ' display: flex; flex-direction: row;',
447
454
  ' gap: 0.5em; align-items: flex-end;',
448
- ' max-width: 78ch; width: 100%;',
449
- ' padding-left: 1.5em;',
455
+ ' max-width: 78ch; align-self: center; width: 100%;',
450
456
  '}',
451
457
  '.cumulus-attach-btn {',
452
458
  ' width: 2.7em; height: 2.7em; flex-shrink: 0;',
@@ -719,8 +725,9 @@
719
725
  '.cumulus-thread-panel:first-child { border-left: none; }',
720
726
  '.cumulus-thread-panel-header {',
721
727
  ' display: flex; align-items: center; justify-content: space-between;',
722
- ' padding: 0 0.85em;',
723
- ' height: 40px;',
728
+ ' flex-wrap: wrap; gap: 4px;',
729
+ ' padding: 4px 0.85em;',
730
+ ' min-height: 40px;',
724
731
  ' background: #2d2d2d;',
725
732
  ' border-bottom: 1px solid #3a3a3a;',
726
733
  ' flex-shrink: 0;',
@@ -767,6 +774,16 @@
767
774
  ' border-radius: 3px; font-family: inherit; white-space: nowrap;',
768
775
  '}',
769
776
  '.cumulus-panel-action-btn:hover { color: #ddd; border-color: #555; }',
777
+ /* ── Model selector dropdown ── */
778
+ '.cumulus-model-select {',
779
+ ' background: #2a2a2a; color: #aaa; border: 1px solid #444;',
780
+ ' border-radius: 4px; padding: 2px 6px; font-size: 11px;',
781
+ ' font-family: inherit; cursor: pointer; max-width: 140px;',
782
+ ' outline: none; flex-shrink: 0;',
783
+ '}',
784
+ '.cumulus-model-select:hover { border-color: #666; color: #ddd; }',
785
+ '.cumulus-model-select:focus { border-color: #0066cc; }',
786
+ '.cumulus-model-select option { background: #2a2a2a; color: #ccc; }',
770
787
  '.cumulus-panel-action-btn.active { color: #e0e0e0; border-color: #0066cc; background: rgba(0,102,204,0.12); }',
771
788
 
772
789
  /* ── Context menu (right-click on thread) ── */
@@ -1041,8 +1058,8 @@
1041
1058
  ' .cumulus-thread-panel { border-left: none; border-top: 1px solid #333; }',
1042
1059
  ' .cumulus-thread-panel:first-child { border-top: none; }',
1043
1060
 
1044
- /* Full-width message content on mobile */
1045
- ' .cumulus-msg.assistant > * { max-width: 100%; }',
1061
+ /* Full-width message content */
1062
+ ' .cumulus-msg-row { max-width: 100%; }',
1046
1063
  ' .cumulus-input-row { max-width: 100%; }',
1047
1064
  ' .cumulus-attach-strip { max-width: 100%; }',
1048
1065
 
@@ -1051,12 +1068,10 @@
1051
1068
  ' .cumulus-input-area { padding: 0.5em 0.65em 0.6em; }',
1052
1069
 
1053
1070
  /* User bubble can be wider on mobile */
1054
- ' .cumulus-msg-row:has(.user) { max-width: 100%; }',
1055
1071
  ' .cumulus-msg.user { max-width: 85%; }',
1056
1072
 
1057
1073
  /* Tables scroll horizontally */
1058
- ' .cumulus-msg table:not(.blex-block-container table) { display: block; overflow-x: auto; white-space: nowrap; }',
1059
- ' .blex-block-container table { display: table; overflow-x: visible; white-space: normal; }',
1074
+ ' .cumulus-msg table { display: block; overflow-x: auto; white-space: nowrap; }',
1060
1075
 
1061
1076
  /* Code blocks: ensure horizontal scroll works */
1062
1077
  ' .code-block-wrapper pre { font-size: 0.85em; }',
@@ -1121,12 +1136,7 @@
1121
1136
  function formatTime(timestamp) {
1122
1137
  if (!timestamp) return null;
1123
1138
  var d = new Date(timestamp);
1124
- var hh = String(d.getHours()).padStart(2, '0');
1125
- var mm = String(d.getMinutes()).padStart(2, '0');
1126
- var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1127
- var mon = months[d.getMonth()];
1128
- var dd = String(d.getDate()).padStart(2, '0');
1129
- return hh + ':' + mm + ' ' + mon + '/' + dd;
1139
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1130
1140
  }
1131
1141
 
1132
1142
  function appendTimestamp(parent, timestamp) {
@@ -1159,198 +1169,183 @@
1159
1169
  var body = document.createElement('div');
1160
1170
  var mdResult = renderMarkdown(agent.body);
1161
1171
  body.innerHTML = mdResult.html;
1162
- renderBlexBlocks(body, mdResult.blexBlocks, null);
1163
1172
  el.appendChild(body);
1173
+ renderBlexBlocks(body, mdResult.blexBlocks, false);
1164
1174
  return el;
1165
1175
  }
1166
1176
 
1167
- // ── Markdown ──────────────────────────────────────────────────────────────
1177
+ // ── Blex Integration ─────────────────────────────────────────────────────
1168
1178
 
1169
- // ─── Blex block handle tracking ─────────────────────────────────────────────
1170
- // Map: msgEl Array<BlockHandle> for cleanup on message delete/revert
1171
- var blexHandles = new WeakMap();
1179
+ // WeakMap: DOM element -> BlockHandle[] for lifecycle management
1180
+ var blexHandles = typeof WeakMap !== 'undefined' ? new WeakMap() : null;
1172
1181
 
1173
- /**
1174
- * Parse blex fenced blocks from markdown text.
1175
- * Returns { text: processedText, blocks: [{type, json, id}] }
1176
- * Fences: ~~~blex:TYPE\n{json}\n~~~ or ```blex:TYPE\n{json}\n```
1177
- */
1182
+ // Extract ~~~blex:TYPE\n{json}\n~~~ fences from text
1183
+ // Returns { text: string (with placeholders), blocks: Array<{type, json, idx}> }
1178
1184
  function extractBlexBlocks(text) {
1179
1185
  var blocks = [];
1180
1186
  var BLEX_PREFIX = '\x00BLEX_';
1181
- var processed = text.replace(
1182
- /(?:~~~|```)blex:(\w[\w-]*)\n([\s\S]*?)(?:~~~|```)/g,
1183
- function (_, type, json) {
1184
- var idx = blocks.length;
1185
- var id = 'blex-' + idx + '-' + Date.now();
1186
- blocks.push({ type: type, json: json.trim(), id: id });
1187
- return BLEX_PREFIX + idx + '\x00';
1188
- }
1189
- );
1190
- return { text: processed, blocks: blocks };
1187
+ var replaced = text.replace(/~~~blex:(\w[\w-]*)\n([\s\S]*?)\n~~~/g, function (_, type, json) {
1188
+ var idx = blocks.length;
1189
+ blocks.push({ type: type, json: json.trim(), idx: idx });
1190
+ return BLEX_PREFIX + idx + '\x00';
1191
+ });
1192
+ return { text: replaced, blocks: blocks };
1191
1193
  }
1192
1194
 
1193
- /**
1194
- * After innerHTML is set, find blex placeholder divs and render blocks.
1195
- * Returns array of BlockHandles for lifecycle management.
1196
- */
1197
- function renderBlexBlocks(container, blocks, inputEl, chipCtx) {
1198
- if (!blocks || !blocks.length || typeof window.Blex === 'undefined') return;
1199
- var handles = [];
1200
- var placeholders = container.querySelectorAll('[data-blex-idx]');
1201
- placeholders.forEach(function (ph) {
1202
- var idx = parseInt(ph.getAttribute('data-blex-idx'), 10);
1203
- var block = blocks[idx];
1204
- if (!block) return;
1205
-
1206
- // Try to parse JSON
1207
- var data;
1195
+ // Render blex blocks into placeholder divs within a message element
1196
+ function renderBlexBlocks(el, blexBlocks, isStreaming) {
1197
+ if (!blexBlocks || blexBlocks.length === 0) return;
1198
+ if (typeof Blex === 'undefined') {
1199
+ console.warn('[blex] Blex library not loaded — blex.min.js may have failed to fetch');
1200
+ return;
1201
+ }
1202
+ var placeholders = el.querySelectorAll('.blex-block-container');
1203
+
1204
+ placeholders.forEach(function (container) {
1205
+ var idx = parseInt(container.getAttribute('data-blex-idx'), 10);
1206
+ if (isNaN(idx) || idx >= blexBlocks.length) return;
1207
+ var block = blexBlocks[idx];
1208
+
1208
1209
  try {
1209
- data = JSON.parse(block.json);
1210
- } catch (_firstErr) {
1211
- // Claude sometimes outputs real newlines inside JSON string values
1212
- // instead of \n escape sequences. Fix by escaping newlines within strings.
1213
- try {
1214
- var fixed = block.json.replace(
1215
- /"(?:[^"\\]|\\.)*"/g,
1216
- function (match) {
1217
- return match
1218
- .replace(/\n/g, '\\n')
1219
- .replace(/\r/g, '\\r')
1220
- .replace(/\t/g, '\\t');
1221
- }
1222
- );
1223
- data = JSON.parse(fixed);
1224
- } catch (e) {
1225
- // Show parse error in a fallback
1226
- ph.innerHTML =
1227
- '<pre style="color:#f44;font-size:0.85em;">blex:' +
1228
- escapeHtml(block.type) +
1229
- ' — invalid JSON: ' +
1230
- escapeHtml(e.message) +
1231
- '</pre>';
1232
- return;
1233
- }
1234
- }
1210
+ var data = JSON.parse(block.json);
1211
+ var blockObj = { type: block.type, id: block.type + '-' + idx, data: data };
1235
1212
 
1236
- var contentBlock = { type: block.type, id: block.id, data: data };
1237
-
1238
- // Render the block
1239
- window.Blex.renderBlock(contentBlock, ph)
1240
- .then(function (handle) {
1241
- handles.push(handle);
1242
- // Wire interaction → all interactions become chips
1243
- if (handle && handle.onInteraction && inputEl) {
1244
- handle.onInteraction(function (interaction) {
1245
- if (!interaction || !interaction.serialized || !interaction.serialized.trim()) return;
1246
- // Fallback: no chip tray, inject into input directly
1247
- if (!chipCtx) {
1248
- inputEl.value =
1249
- (inputEl.value ? inputEl.value + '\n' : '') + interaction.serialized;
1250
- inputEl.focus();
1251
- inputEl.dispatchEvent(new Event('input', { bubbles: true }));
1252
- return;
1213
+ if (isStreaming) {
1214
+ // During streaming, show placeholder skeleton
1215
+ Blex.renderPlaceholder(block.type, container);
1216
+ } else {
1217
+ // Final render — full interactive block
1218
+ Blex.renderBlock(blockObj, container)
1219
+ .then(function (handle) {
1220
+ // Store handle immediately (inside async callback, not after sync loop)
1221
+ if (blexHandles) {
1222
+ var existing = blexHandles.get(el) || [];
1223
+ existing.push(handle);
1224
+ blexHandles.set(el, existing);
1253
1225
  }
1254
- // Per-block dedup: if this block already has a chip, replace it
1255
- var existingIdx = -1;
1256
- var existingChipEl = null;
1257
- if (interaction.blockId) {
1258
- for (var ci = 0; ci < chipCtx.pending.length; ci++) {
1259
- if (chipCtx.pending[ci].blockId === interaction.blockId) {
1260
- existingIdx = ci;
1261
- // Find the corresponding DOM chip
1262
- var chips = chipCtx.tray.querySelectorAll('[data-testid="webchat-interaction-chip"]');
1263
- // Count how many pending items before this index share blockId matches
1264
- var domIdx = 0;
1265
- for (var cj = 0; cj < ci; cj++) domIdx++;
1266
- existingChipEl = chips[domIdx] || null;
1267
- break;
1226
+ if (handle && handle.onInteraction) {
1227
+ // Wire interaction handler
1228
+ handle.onInteraction(function (interaction) {
1229
+ // Find the panel — try el.closest first, fall back to document query
1230
+ // (el may be detached from DOM if renderPanelMessages rebuilt the message list)
1231
+ var panel = el.closest('.cumulus-thread-panel') || el.closest('.cumulus-panel');
1232
+ if (!panel) {
1233
+ panel =
1234
+ document.querySelector('.cumulus-thread-panel') ||
1235
+ document.querySelector('.cumulus-panel');
1268
1236
  }
1269
- }
1270
- }
1271
- // If replacing an existing chip, remove the old one first
1272
- if (existingIdx !== -1) {
1273
- chipCtx.pending.splice(existingIdx, 1);
1274
- if (existingChipEl && existingChipEl.parentNode) {
1275
- existingChipEl.parentNode.removeChild(existingChipEl);
1276
- }
1277
- }
1278
- // Build chip for ALL interaction types
1279
- var chip = document.createElement('span');
1280
- chip.className = 'cumulus-interaction-chip' + (interaction.immediate ? ' cumulus-interaction-chip--immediate' : '');
1281
- chip.setAttribute('data-testid', 'webchat-interaction-chip');
1282
- if (interaction.icon) {
1283
- var iconSpan = document.createElement('span');
1284
- iconSpan.className = 'cumulus-interaction-chip-icon';
1285
- iconSpan.textContent = interaction.icon;
1286
- chip.appendChild(iconSpan);
1287
- }
1288
- var labelSpan = document.createElement('span');
1289
- labelSpan.className = 'cumulus-interaction-chip-label';
1290
- labelSpan.textContent = interaction.summary || interaction.serialized;
1291
- labelSpan.setAttribute('title', interaction.serialized);
1292
- chip.appendChild(labelSpan);
1293
- if (interaction.revertable) {
1294
- var dismiss = document.createElement('button');
1295
- dismiss.className = 'cumulus-interaction-chip-dismiss';
1296
- dismiss.textContent = '\u00d7';
1297
- dismiss.setAttribute('title', 'Remove');
1298
- dismiss.addEventListener('click', function () {
1299
- if (handle.revertInteraction) {
1300
- handle.revertInteraction(interaction);
1237
+ var inputEl = panel
1238
+ ? panel.querySelector('[data-testid="webchat-input"]')
1239
+ : document.querySelector('[data-testid="webchat-input"]');
1240
+
1241
+ if (inputEl && interaction.serialized) {
1242
+ if (interaction.immediate) {
1243
+ // Immediate: inject into textarea and auto-send
1244
+ inputEl.value = interaction.serialized;
1245
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
1246
+ // Find and click the send button
1247
+ var sendBtn = panel ? panel.querySelector('[data-testid*="send"]') : null;
1248
+ if (sendBtn) {
1249
+ sendBtn.click();
1250
+ }
1251
+ } else {
1252
+ // Deferred: add to chip tray
1253
+ addInteractionChip(panel, interaction, handle);
1254
+ }
1301
1255
  }
1302
- var idx = chipCtx.pending.indexOf(interaction);
1303
- if (idx !== -1) chipCtx.pending.splice(idx, 1);
1304
- chip.parentNode && chip.parentNode.removeChild(chip);
1305
- inputEl.dispatchEvent(new Event('input', { bubbles: true }));
1306
1256
  });
1307
- chip.appendChild(dismiss);
1308
- }
1309
- chipCtx.tray.appendChild(chip);
1310
- chipCtx.pending.push(interaction);
1311
- inputEl.dispatchEvent(new Event('input', { bubbles: true }));
1312
- // Immediate interactions: auto-send after a brief flash
1313
- if (interaction.immediate) {
1314
- chip.style.transition = 'opacity 0.15s';
1315
- setTimeout(function () {
1316
- // Find and click the send button to trigger sendPanelMessage
1317
- var panel = chipCtx.tray.closest('.cumulus-thread-panel, .cumulus-panel, .cumulus-widget');
1318
- var sendButton = panel && panel.querySelector('[data-testid="webchat-send"]');
1319
- if (sendButton) sendButton.click();
1320
- }, 150);
1321
1257
  }
1258
+ })
1259
+ .catch(function (err) {
1260
+ container.innerHTML =
1261
+ '<pre style="color:#f66;font-size:0.85em;padding:0.5em;">Blex error: ' +
1262
+ (err.message || err) +
1263
+ '</pre>';
1322
1264
  });
1323
- }
1324
- })
1325
- .catch(function (err) {
1326
- ph.innerHTML =
1327
- '<pre style="color:#f44;font-size:0.85em;">blex:' +
1328
- escapeHtml(block.type) +
1329
- ' render error: ' +
1330
- escapeHtml(err.message) +
1331
- '</pre>';
1332
- });
1265
+ }
1266
+ } catch (e) {
1267
+ container.innerHTML =
1268
+ '<pre style="color:#f66;font-size:0.85em;padding:0.5em;">Invalid blex JSON: ' +
1269
+ e.message +
1270
+ '</pre>';
1271
+ }
1333
1272
  });
1334
- blexHandles.set(container, handles);
1273
+
1274
+ // Note: handles are stored inside the async .then() callback above,
1275
+ // not here — by this point the promises haven't resolved yet.
1335
1276
  }
1336
1277
 
1337
- /**
1338
- * Destroy all blex block handles for a message element.
1339
- */
1340
- function destroyBlexBlocks(container) {
1341
- var handles = blexHandles.get(container);
1278
+ // Destroy blex block handles for a message element
1279
+ function destroyBlexBlocks(el) {
1280
+ if (!blexHandles) return;
1281
+ var handles = blexHandles.get(el);
1342
1282
  if (handles) {
1343
1283
  handles.forEach(function (h) {
1344
- if (h && h.destroy) h.destroy();
1284
+ try {
1285
+ h.destroy();
1286
+ } catch (e) {}
1287
+ });
1288
+ blexHandles.delete(el);
1289
+ }
1290
+ }
1291
+
1292
+ // Add a deferred interaction chip to the chip tray
1293
+ function addInteractionChip(panel, interaction, handle) {
1294
+ if (!panel) return;
1295
+ var tray = panel.querySelector('.cumulus-chip-tray');
1296
+ if (!tray) return;
1297
+
1298
+ var chip = document.createElement('span');
1299
+ chip.className = 'cumulus-interaction-chip';
1300
+ chip.setAttribute('data-serialized', interaction.serialized || '');
1301
+
1302
+ var label = document.createElement('span');
1303
+ label.textContent =
1304
+ (interaction.icon || '\u2022') +
1305
+ ' ' +
1306
+ (interaction.summary || interaction.serialized || '').substring(0, 40);
1307
+ chip.appendChild(label);
1308
+
1309
+ if (interaction.revertable) {
1310
+ var dismiss = document.createElement('span');
1311
+ dismiss.className = 'chip-dismiss';
1312
+ dismiss.textContent = '\u00d7';
1313
+ dismiss.addEventListener('click', function () {
1314
+ if (handle && handle.revertInteraction) {
1315
+ try {
1316
+ handle.revertInteraction(interaction);
1317
+ } catch (e) {}
1318
+ }
1319
+ chip.remove();
1345
1320
  });
1346
- blexHandles.delete(container);
1321
+ chip.appendChild(dismiss);
1347
1322
  }
1323
+
1324
+ tray.appendChild(chip);
1325
+ }
1326
+
1327
+ // Collect and clear chips from a panel's chip tray
1328
+ function collectAndClearChips(panel) {
1329
+ if (!panel) return '';
1330
+ var tray = panel.querySelector('.cumulus-chip-tray');
1331
+ if (!tray) return '';
1332
+ var chips = tray.querySelectorAll('.cumulus-interaction-chip');
1333
+ var texts = [];
1334
+ chips.forEach(function (chip) {
1335
+ var s = chip.getAttribute('data-serialized');
1336
+ if (s) texts.push(s);
1337
+ });
1338
+ tray.innerHTML = '';
1339
+ return texts.join('\n');
1348
1340
  }
1349
1341
 
1342
+ // ── Markdown ──────────────────────────────────────────────────────────────
1343
+
1350
1344
  function renderMarkdown(text) {
1351
- // Phase 0.5: Extract blex fenced blocks replace with unique tokens
1345
+ // Phase 0.5: Extract blex fences BEFORE code blocks
1352
1346
  var blexResult = extractBlexBlocks(text);
1353
1347
  text = blexResult.text;
1348
+ var blexBlocks = blexResult.blocks;
1354
1349
 
1355
1350
  // Phase 1: Extract fenced code blocks — replace with unique tokens
1356
1351
  var codeBlocks = [];
@@ -1365,8 +1360,10 @@
1365
1360
  });
1366
1361
 
1367
1362
  // Phase 2: Escape HTML in the non-code portions
1368
- // We need to escape line by line between tokens, preserving both codeblock and blex tokens
1369
- var parts = processed.split(/(\x00CODEBLOCK_\d+\x00|\x00BLEX_\d+\x00)/);
1363
+ // We need to escape line by line between tokens
1364
+ // Split by BOTH code block and blex tokens — escapeHtml uses DOM textContent
1365
+ // which strips null bytes (\x00), destroying our placeholder tokens
1366
+ var parts = processed.split(/(\x00(?:CODEBLOCK|BLEX)_\d+\x00)/);
1370
1367
  var escapedParts = parts.map(function (part) {
1371
1368
  if (/^\x00(?:CODEBLOCK|BLEX)_\d+\x00$/.test(part)) return part; // keep token as-is
1372
1369
  return escapeHtml(part);
@@ -1425,19 +1422,11 @@
1425
1422
  });
1426
1423
 
1427
1424
  // Phase 7.5: Reinsert blex block placeholders
1428
- if (blexResult.blocks.length > 0) {
1429
- html = html.replace(/\x00BLEX_(\d+)\x00/g, function (_, idx) {
1430
- return (
1431
- '<div class="blex-block-container" data-blex-idx="' +
1432
- idx +
1433
- '" data-testid="blex-block-' +
1434
- idx +
1435
- '"></div>'
1436
- );
1437
- });
1438
- }
1425
+ html = html.replace(/\x00BLEX_(\d+)\x00/g, function (_, idx) {
1426
+ return '<div class="blex-block-container" data-blex-idx="' + idx + '"></div>';
1427
+ });
1439
1428
 
1440
- return { html: html, blexBlocks: blexResult.blocks };
1429
+ return { html: html, blexBlocks: blexBlocks };
1441
1430
  }
1442
1431
 
1443
1432
  function buildCodeBlock(lang, escapedCode, tokenId) {
@@ -1679,10 +1668,14 @@
1679
1668
  var reconnectTimer = null;
1680
1669
  var reconnectDelay = 1000;
1681
1670
  var destroyed = false;
1671
+ var lastPongTime = 0;
1672
+ var staleCheckTimer = null;
1673
+ var currentStatus = 'disconnected';
1682
1674
 
1683
1675
  function connect() {
1684
1676
  if (destroyed) return;
1685
1677
  onStatus('connecting');
1678
+ currentStatus = 'connecting';
1686
1679
  ws = new WebSocket(wsUrl);
1687
1680
 
1688
1681
  ws.onopen = function () {
@@ -1690,15 +1683,24 @@
1690
1683
  ws.close();
1691
1684
  return;
1692
1685
  }
1686
+ currentStatus = 'connected';
1693
1687
  onStatus('connected');
1694
1688
  reconnectDelay = 1000;
1689
+ lastPongTime = Date.now();
1695
1690
  ws.send(JSON.stringify({ type: 'auth', apiKey: apiKey }));
1696
1691
  if (!skipHistory) {
1697
1692
  ws.send(JSON.stringify({ type: 'history', threadName: sessionId, limit: 200 }));
1698
1693
  }
1694
+ startStaleCheck();
1699
1695
  };
1700
1696
 
1701
1697
  ws.onmessage = function (event) {
1698
+ // Any message from server proves the connection is alive
1699
+ lastPongTime = Date.now();
1700
+ if (currentStatus === 'stale') {
1701
+ currentStatus = 'connected';
1702
+ onStatus('connected');
1703
+ }
1702
1704
  try {
1703
1705
  var data = JSON.parse(event.data);
1704
1706
  onMessage(data);
@@ -1709,7 +1711,9 @@
1709
1711
 
1710
1712
  ws.onclose = function () {
1711
1713
  if (destroyed) return;
1714
+ currentStatus = 'disconnected';
1712
1715
  onStatus('disconnected');
1716
+ stopStaleCheck();
1713
1717
  scheduleReconnect();
1714
1718
  };
1715
1719
 
@@ -1718,6 +1722,26 @@
1718
1722
  };
1719
1723
  }
1720
1724
 
1725
+ function startStaleCheck() {
1726
+ stopStaleCheck();
1727
+ staleCheckTimer = setInterval(function () {
1728
+ if (ws && ws.readyState === WebSocket.OPEN && Date.now() - lastPongTime > 60000) {
1729
+ // No message from server in 60s — connection may be half-open
1730
+ if (currentStatus !== 'stale') {
1731
+ currentStatus = 'stale';
1732
+ onStatus('stale');
1733
+ }
1734
+ }
1735
+ }, 15000);
1736
+ }
1737
+
1738
+ function stopStaleCheck() {
1739
+ if (staleCheckTimer) {
1740
+ clearInterval(staleCheckTimer);
1741
+ staleCheckTimer = null;
1742
+ }
1743
+ }
1744
+
1721
1745
  function scheduleReconnect() {
1722
1746
  if (reconnectTimer || destroyed) return;
1723
1747
  reconnectTimer = setTimeout(function () {
@@ -1733,8 +1757,26 @@
1733
1757
  }
1734
1758
  }
1735
1759
 
1760
+ function reconnect() {
1761
+ // Force reconnect — close current connection and reconnect immediately
1762
+ console.log('[CumulusChat] Manual reconnect triggered');
1763
+ stopStaleCheck();
1764
+ if (reconnectTimer) {
1765
+ clearTimeout(reconnectTimer);
1766
+ reconnectTimer = null;
1767
+ }
1768
+ if (ws) {
1769
+ ws.onclose = null;
1770
+ ws.close();
1771
+ ws = null;
1772
+ }
1773
+ reconnectDelay = 1000;
1774
+ connect();
1775
+ }
1776
+
1736
1777
  function close() {
1737
1778
  destroyed = true;
1779
+ stopStaleCheck();
1738
1780
  if (reconnectTimer) {
1739
1781
  clearTimeout(reconnectTimer);
1740
1782
  reconnectTimer = null;
@@ -1746,8 +1788,12 @@
1746
1788
  }
1747
1789
  }
1748
1790
 
1791
+ function getStatus() {
1792
+ return currentStatus;
1793
+ }
1794
+
1749
1795
  connect();
1750
- return { send: send, close: close };
1796
+ return { send: send, close: close, reconnect: reconnect, getStatus: getStatus };
1751
1797
  }
1752
1798
 
1753
1799
  // ─── Widget ──────────────────────────────────────────────────────────────────
@@ -1836,12 +1882,6 @@
1836
1882
  attachStrip.style.display = 'none';
1837
1883
  inputArea.appendChild(attachStrip);
1838
1884
 
1839
- var chipTray = document.createElement('div');
1840
- chipTray.className = 'cumulus-chip-tray';
1841
- chipTray.setAttribute('data-testid', 'webchat-chip-tray');
1842
- inputArea.appendChild(chipTray);
1843
- var pendingChips = [];
1844
-
1845
1885
  var inputRow = document.createElement('div');
1846
1886
  inputRow.className = 'cumulus-input-row';
1847
1887
 
@@ -1917,6 +1957,18 @@
1917
1957
  return lineCount > 3;
1918
1958
  }
1919
1959
 
1960
+ function isSystemMessage(content) {
1961
+ return typeof content === 'string' && content.startsWith('[System]');
1962
+ }
1963
+
1964
+ function buildSystemMsgEl(content) {
1965
+ var el = document.createElement('div');
1966
+ el.className = 'cumulus-msg system';
1967
+ el.setAttribute('data-testid', 'webchat-system-msg');
1968
+ el.textContent = content.replace(/^\[System\]\s*/, '');
1969
+ return el;
1970
+ }
1971
+
1920
1972
  function buildUserMsgEl(msg) {
1921
1973
  var el = document.createElement('div');
1922
1974
  el.className = 'cumulus-msg user' + (isWideUserMessage(msg.content) ? ' wide' : '');
@@ -1955,34 +2007,9 @@
1955
2007
  if (isStreaming) {
1956
2008
  el.innerHTML += '<span class="cumulus-cursor"></span>';
1957
2009
  }
1958
- // Render blex blocks
1959
- if (mdResult.blexBlocks.length > 0) {
1960
- if (isStreaming && typeof window.Blex !== 'undefined') {
1961
- // During streaming: show placeholders
1962
- el.querySelectorAll('[data-blex-idx]').forEach(function (ph) {
1963
- var idx = parseInt(ph.getAttribute('data-blex-idx'), 10);
1964
- var block = mdResult.blexBlocks[idx];
1965
- if (block) window.Blex.renderPlaceholder(block.type, ph);
1966
- });
1967
- } else if (!isStreaming) {
1968
- // Final render: full blex blocks with interaction wiring
1969
- // Defer rendering until the element is in the DOM (el.closest needs a parent)
1970
- var _blexBlocks = mdResult.blexBlocks;
1971
- requestAnimationFrame(function () {
1972
- var panelEl = el.closest('.cumulus-panel, .cumulus-widget, .cumulus-thread-panel');
1973
- var blexInputEl = panelEl
1974
- ? panelEl.querySelector('.cumulus-input, .cumulus-standalone-input')
1975
- : null;
1976
- var blexChipTray = panelEl
1977
- ? panelEl.querySelector('.cumulus-chip-tray')
1978
- : null;
1979
- var blexChipCtx = blexChipTray
1980
- ? { tray: blexChipTray, pending: blexChipTray._pendingChips || (blexChipTray._pendingChips = []) }
1981
- : null;
1982
- console.log('[blex-render] panelEl:', !!panelEl, 'inputEl:', !!blexInputEl, 'chipTray:', !!blexChipTray, 'chipCtx:', !!blexChipCtx, 'blocks:', _blexBlocks.length);
1983
- renderBlexBlocks(el, _blexBlocks, blexInputEl, blexChipCtx);
1984
- });
1985
- }
2010
+ // Render blex blocks — skip during streaming to avoid destroy/recreate churn
2011
+ if (!isStreaming) {
2012
+ renderBlexBlocks(el, mdResult.blexBlocks, false);
1986
2013
  }
1987
2014
  el.querySelectorAll('.code-block-copy-btn').forEach(function (btn) {
1988
2015
  btn.addEventListener('click', function () {
@@ -2005,15 +2032,30 @@
2005
2032
  return el;
2006
2033
  }
2007
2034
 
2008
- var _embeddedStreamingRow = null;
2035
+ // Incremental streaming update — only touch the streaming element
2036
+ function updateStreamOnly() {
2037
+ var streamEl = messagesEl.querySelector('[data-testid="webchat-streaming"]');
2038
+ if (streamEl) {
2039
+ var parentRow = streamEl.closest('.cumulus-msg-row') || streamEl.parentElement;
2040
+ if (parentRow) {
2041
+ var newEl = buildAssistantMsgEl(streamBuffer, true);
2042
+ parentRow.replaceChild(newEl, streamEl);
2043
+ }
2044
+ } else if (streaming) {
2045
+ var row = document.createElement('div');
2046
+ row.className = 'cumulus-msg-row';
2047
+ row.appendChild(buildAssistantMsgEl(streamBuffer, true));
2048
+ messagesEl.appendChild(row);
2049
+ }
2050
+ scrollToBottom();
2051
+ }
2009
2052
 
2010
2053
  function renderMessages() {
2011
- // Destroy existing blex block handles before clearing DOM
2054
+ // Destroy blex blocks before clearing DOM
2012
2055
  messagesEl.querySelectorAll('.cumulus-msg.assistant').forEach(function (m) {
2013
2056
  destroyBlexBlocks(m);
2014
2057
  });
2015
2058
  messagesEl.innerHTML = '';
2016
- _embeddedStreamingRow = null;
2017
2059
  // Apply selection mode class
2018
2060
  if (selectionMode) {
2019
2061
  messagesEl.classList.add('cumulus-selection-mode');
@@ -2048,7 +2090,9 @@
2048
2090
  })(msg.id);
2049
2091
  row.appendChild(cb);
2050
2092
  }
2051
- if (msg.role === 'user') {
2093
+ if (msg.role === 'user' && isSystemMessage(msg.content)) {
2094
+ row.appendChild(buildSystemMsgEl(msg.content));
2095
+ } else if (msg.role === 'user') {
2052
2096
  var agent = parseAgentMessage(msg.content);
2053
2097
  if (agent) {
2054
2098
  row.appendChild(buildAgentMsgEl(agent));
@@ -2066,41 +2110,11 @@
2066
2110
  row.className = 'cumulus-msg-row';
2067
2111
  row.appendChild(buildAssistantMsgEl(streamBuffer, true));
2068
2112
  messagesEl.appendChild(row);
2069
- _embeddedStreamingRow = row;
2070
2113
  }
2071
2114
  scrollToBottom();
2072
2115
  updateEmbeddedSelectionBar();
2073
2116
  }
2074
2117
 
2075
- // Incremental streaming update — only touches the streaming element
2076
- function updateEmbeddedStreamingOnly() {
2077
- if (!streaming) {
2078
- _embeddedStreamingRow = null;
2079
- renderMessages();
2080
- return;
2081
- }
2082
- if (!_embeddedStreamingRow) {
2083
- renderMessages();
2084
- return;
2085
- }
2086
- var el = _embeddedStreamingRow.querySelector('.cumulus-msg.assistant');
2087
- if (el && streamBuffer) {
2088
- var mdResult = renderMarkdown(streamBuffer);
2089
- el.innerHTML = mdResult.html + '<span class="cumulus-cursor"></span>';
2090
- if (mdResult.blexBlocks.length > 0 && typeof window.Blex !== 'undefined') {
2091
- el.querySelectorAll('[data-blex-idx]').forEach(function (ph) {
2092
- var idx = parseInt(ph.getAttribute('data-blex-idx'), 10);
2093
- var block = mdResult.blexBlocks[idx];
2094
- if (block) window.Blex.renderPlaceholder(block.type, ph);
2095
- });
2096
- }
2097
- } else if (el && !streamBuffer) {
2098
- el.innerHTML =
2099
- '<span class="cumulus-typing-dots"><span></span><span></span><span></span></span>';
2100
- }
2101
- scrollToBottom();
2102
- }
2103
-
2104
2118
  function updateEmbeddedSelectionBar() {
2105
2119
  var existingBar = panel.querySelector('.cumulus-selection-bar');
2106
2120
  if (existingBar) existingBar.remove();
@@ -2226,7 +2240,25 @@
2226
2240
  var dot = panel.querySelector('.cumulus-status-dot');
2227
2241
  var text = panel.querySelector('[data-testid="webchat-status-text"]');
2228
2242
  if (dot) dot.className = 'cumulus-status-dot ' + status;
2229
- if (text) text.textContent = status.charAt(0).toUpperCase() + status.slice(1);
2243
+ if (text) {
2244
+ var label =
2245
+ status === 'stale'
2246
+ ? 'Stale — tap to reconnect'
2247
+ : status === 'disconnected'
2248
+ ? 'Disconnected — tap to reconnect'
2249
+ : status.charAt(0).toUpperCase() + status.slice(1);
2250
+ text.textContent = label;
2251
+ }
2252
+ }
2253
+
2254
+ // ── Click status to force reconnect ──
2255
+ var statusEl = panel.querySelector('.cumulus-header-status');
2256
+ if (statusEl) {
2257
+ statusEl.addEventListener('click', function () {
2258
+ if (connection && connectionStatus !== 'connecting') {
2259
+ connection.reconnect();
2260
+ }
2261
+ });
2230
2262
  }
2231
2263
 
2232
2264
  function renderAttachStrip() {
@@ -2316,22 +2348,9 @@
2316
2348
  break;
2317
2349
  case 'history':
2318
2350
  if (data.messages && data.messages.length > 0) {
2319
- var embCompleted = [];
2320
- var embStreamingMsg = null;
2321
- for (var ehi = 0; ehi < data.messages.length; ehi++) {
2322
- var ehm = data.messages[ehi];
2323
- if (ehm.streaming) {
2324
- embStreamingMsg = ehm;
2325
- } else {
2326
- embCompleted.push({ id: ehm.id, role: ehm.role, content: ehm.content, timestamp: ehm.timestamp });
2327
- }
2328
- }
2329
- messages = embCompleted;
2330
- if (embStreamingMsg && data.streaming) {
2331
- streaming = true;
2332
- streamBuffer = embStreamingMsg.content || '';
2333
- updateSendBtn();
2334
- }
2351
+ messages = data.messages.map(function (m) {
2352
+ return { id: m.id, role: m.role, content: m.content, timestamp: m.timestamp };
2353
+ });
2335
2354
  renderMessages();
2336
2355
  }
2337
2356
  break;
@@ -2360,12 +2379,11 @@
2360
2379
  renderMessages();
2361
2380
  // Reload history
2362
2381
  if (connection)
2363
- connection.send({ type: 'history', threadName: sessionId, limit: 200 });
2382
+ connection.send({ type: 'history', threadName: sessionId, limit: 50 });
2364
2383
  }
2365
2384
  embeddedStreamTimer = null;
2366
2385
  }, EMBEDDED_STREAM_TIMEOUT);
2367
- // Incremental update: only touch streaming element, preserve blex blocks
2368
- updateEmbeddedStreamingOnly();
2386
+ updateStreamOnly();
2369
2387
  break;
2370
2388
  case 'segment':
2371
2389
  break;
@@ -2448,17 +2466,7 @@
2448
2466
  }
2449
2467
 
2450
2468
  function sendMessage() {
2451
- // Prepend deferred interaction chips to the message
2452
- var chipTrayEl = panel.querySelector('.cumulus-chip-tray');
2453
- var chipPending = chipTrayEl && chipTrayEl._pendingChips ? chipTrayEl._pendingChips : [];
2454
- var chipPrefix = '';
2455
- if (chipPending.length > 0) {
2456
- chipPrefix = chipPending.map(function (c) { return c.serialized; }).join('\n');
2457
- chipPending.length = 0;
2458
- if (chipTrayEl) chipTrayEl.innerHTML = '';
2459
- }
2460
- var rawText = input.value.trim();
2461
- var text = chipPrefix ? (chipPrefix + (rawText ? '\n' + rawText : '')) : rawText;
2469
+ var text = input.value.trim();
2462
2470
 
2463
2471
  // Interjection: user sends while streaming
2464
2472
  if (streaming) {
@@ -2646,7 +2654,7 @@
2646
2654
  // Reload history to get the real response if server completed
2647
2655
  s.historyLoaded = false;
2648
2656
  if (connection) {
2649
- connection.send({ type: 'history', threadName: threadName, limit: 200 });
2657
+ connection.send({ type: 'history', threadName: threadName, limit: 50 });
2650
2658
  }
2651
2659
  refreshThreadPanel(threadName);
2652
2660
  }
@@ -2686,6 +2694,12 @@
2686
2694
  historyLoaded: false,
2687
2695
  selectionMode: false,
2688
2696
  selectedIds: {},
2697
+ draft: '', // persists unsent textarea text across thread switches
2698
+ // Pagination state
2699
+ loadingOlder: false,
2700
+ hasOlderMessages: false,
2701
+ oldestOffset: -1, // -1 = unknown, set on first history response
2702
+ model: 'claude', // default model
2689
2703
  };
2690
2704
  }
2691
2705
  return threadStates[threadName];
@@ -2821,6 +2835,20 @@
2821
2835
  });
2822
2836
  }
2823
2837
 
2838
+ // ── Click status indicator to force reconnect ──
2839
+ var appStatusEl = topbar.querySelector('.cumulus-header-status');
2840
+ if (appStatusEl) {
2841
+ appStatusEl.addEventListener('click', function () {
2842
+ if (connection && connectionStatus !== 'connecting') {
2843
+ connection.reconnect();
2844
+ // Force history reload for all loaded threads
2845
+ Object.keys(threadStates).forEach(function (tn) {
2846
+ threadStates[tn].historyLoaded = false;
2847
+ });
2848
+ }
2849
+ });
2850
+ }
2851
+
2824
2852
  standaloneRoot.appendChild(authWrapper);
2825
2853
  standaloneRoot.appendChild(appLayout);
2826
2854
  container.appendChild(standaloneRoot);
@@ -2828,16 +2856,27 @@
2828
2856
  // ── Status update (updates both auth-phase and app-phase dots) ──
2829
2857
  function updateStatus(status) {
2830
2858
  connectionStatus = status;
2859
+ var label =
2860
+ status === 'stale'
2861
+ ? 'Stale — tap to reconnect'
2862
+ : status === 'disconnected'
2863
+ ? 'Disconnected — tap to reconnect'
2864
+ : status.charAt(0).toUpperCase() + status.slice(1);
2831
2865
  // Auth phase dot
2832
2866
  var dot = authWrapper.querySelector('[data-testid="webchat-status-dot"]');
2833
2867
  var text = authWrapper.querySelector('[data-testid="webchat-status-text"]');
2834
2868
  if (dot) dot.className = 'cumulus-status-dot ' + status;
2835
- if (text) text.textContent = status.charAt(0).toUpperCase() + status.slice(1);
2869
+ if (text) text.textContent = label;
2836
2870
  // App phase dot
2837
2871
  var dotApp = appLayout.querySelector('[data-testid="webchat-status-dot-app"]');
2838
2872
  var textApp = appLayout.querySelector('[data-testid="webchat-status-text-app"]');
2839
2873
  if (dotApp) dotApp.className = 'cumulus-status-dot ' + status;
2840
- if (textApp) textApp.textContent = status.charAt(0).toUpperCase() + status.slice(1);
2874
+ if (textApp) textApp.textContent = label;
2875
+ // Update per-thread panel status dots
2876
+ var threadDots = appLayout.querySelectorAll('[data-thread-status-dot]');
2877
+ threadDots.forEach(function (d) {
2878
+ d.className = 'cumulus-status-dot ' + status;
2879
+ });
2841
2880
  }
2842
2881
 
2843
2882
  // ── View switching ──
@@ -3154,6 +3193,7 @@
3154
3193
  var state = threadStates[name];
3155
3194
  if (state) {
3156
3195
  state._renderMessages = null;
3196
+ state._updateStreamOnly = null;
3157
3197
  state._updateSendBtn = null;
3158
3198
  }
3159
3199
  renderSidebar();
@@ -3176,6 +3216,7 @@
3176
3216
  var _cs = threadStates[visibleThreads[_ci]];
3177
3217
  if (_cs) {
3178
3218
  _cs._renderMessages = null;
3219
+ _cs._updateStreamOnly = null;
3179
3220
  _cs._updateSendBtn = null;
3180
3221
  }
3181
3222
  }
@@ -3226,6 +3267,99 @@
3226
3267
 
3227
3268
  panelHeader.appendChild(titleLeft);
3228
3269
 
3270
+ // Model selector dropdown
3271
+ var modelSelect = document.createElement('select');
3272
+ modelSelect.className = 'cumulus-model-select';
3273
+ modelSelect.setAttribute('data-testid', 'webchat-model-select');
3274
+ modelSelect.setAttribute('data-thread-model', threadName);
3275
+ // Default option
3276
+ var defaultOpt = document.createElement('option');
3277
+ defaultOpt.value = 'claude';
3278
+ defaultOpt.textContent = 'Claude (CLI)';
3279
+ modelSelect.appendChild(defaultOpt);
3280
+ // Load models from API + thread's saved model
3281
+ (function loadModels() {
3282
+ var xhr = new XMLHttpRequest();
3283
+ xhr.open('GET', '/api/models');
3284
+ xhr.setRequestHeader('X-API-Key', activeApiKey);
3285
+ xhr.onload = function () {
3286
+ if (xhr.status === 200) {
3287
+ try {
3288
+ var resp = JSON.parse(xhr.responseText);
3289
+ modelSelect.innerHTML = '';
3290
+ (resp.models || []).forEach(function (m) {
3291
+ var opt = document.createElement('option');
3292
+ opt.value = m.id;
3293
+ opt.textContent = m.label || m.id;
3294
+ modelSelect.appendChild(opt);
3295
+ });
3296
+ // Find the default model from the API response (global config default)
3297
+ var defaultModel = resp.models.find(function (m) {
3298
+ return m.default;
3299
+ });
3300
+ var defaultModelId = defaultModel
3301
+ ? defaultModel.id
3302
+ : resp.models[0] && resp.models[0].id;
3303
+
3304
+ // Set initial dropdown to the global default (for new threads)
3305
+ if (defaultModelId) {
3306
+ modelSelect.value = defaultModelId;
3307
+ var ts = getThreadState(threadName);
3308
+ ts.model = defaultModelId;
3309
+ }
3310
+
3311
+ // Fetch the thread's saved config to override the default
3312
+ var cfgXhr = new XMLHttpRequest();
3313
+ cfgXhr.open('GET', '/api/thread/' + encodeURIComponent(threadName) + '/config');
3314
+ cfgXhr.setRequestHeader('X-API-Key', activeApiKey);
3315
+ cfgXhr.onload = function () {
3316
+ if (cfgXhr.status === 200) {
3317
+ try {
3318
+ var cfg = JSON.parse(cfgXhr.responseText);
3319
+ // Use thread's saved model, or fall back to global default
3320
+ var selectedModel = cfg.model || defaultModelId;
3321
+ if (selectedModel) {
3322
+ // Check if this model exists in the dropdown options
3323
+ var optionExists = false;
3324
+ for (var i = 0; i < modelSelect.options.length; i++) {
3325
+ if (modelSelect.options[i].value === selectedModel) {
3326
+ optionExists = true;
3327
+ break;
3328
+ }
3329
+ }
3330
+ // Only set if the model exists in the dropdown
3331
+ if (optionExists) {
3332
+ modelSelect.value = selectedModel;
3333
+ var ts2 = getThreadState(threadName);
3334
+ ts2.model = selectedModel;
3335
+ }
3336
+ }
3337
+ } catch (e) {
3338
+ /* ignore */
3339
+ }
3340
+ }
3341
+ };
3342
+ cfgXhr.send();
3343
+ } catch (e) {
3344
+ /* ignore parse errors */
3345
+ }
3346
+ }
3347
+ };
3348
+ xhr.send();
3349
+ })();
3350
+ modelSelect.addEventListener('change', function () {
3351
+ var newModel = modelSelect.value;
3352
+ var ts = getThreadState(threadName);
3353
+ ts.model = newModel;
3354
+ // Persist to thread config via API
3355
+ var xhr = new XMLHttpRequest();
3356
+ xhr.open('PUT', '/api/thread/' + encodeURIComponent(threadName) + '/config');
3357
+ xhr.setRequestHeader('Content-Type', 'application/json');
3358
+ xhr.setRequestHeader('X-API-Key', activeApiKey);
3359
+ xhr.send(JSON.stringify({ model: newModel }));
3360
+ });
3361
+ panelHeader.appendChild(modelSelect);
3362
+
3229
3363
  // Action buttons (include + revert)
3230
3364
  var actions = document.createElement('div');
3231
3365
  actions.className = 'cumulus-panel-actions';
@@ -3280,10 +3414,46 @@
3280
3414
  messagesEl.setAttribute('data-thread-messages', threadName);
3281
3415
  panel.appendChild(messagesEl);
3282
3416
 
3417
+ // Scroll-to-top pagination: load older messages
3418
+ messagesEl.addEventListener('scroll', function () {
3419
+ if (
3420
+ messagesEl.scrollTop < 100 &&
3421
+ state.hasOlderMessages &&
3422
+ !state.loadingOlder &&
3423
+ state.oldestOffset > 0 &&
3424
+ connection
3425
+ ) {
3426
+ state.loadingOlder = true;
3427
+ // Show loading indicator at top
3428
+ var loader = messagesEl.querySelector('.cumulus-load-older');
3429
+ if (!loader) {
3430
+ loader = document.createElement('div');
3431
+ loader.className = 'cumulus-load-older';
3432
+ loader.setAttribute('data-testid', 'webchat-load-older');
3433
+ loader.textContent = 'Loading older messages\u2026';
3434
+ messagesEl.insertBefore(loader, messagesEl.firstChild);
3435
+ }
3436
+ // Request older messages: go back 50 from the oldest we have
3437
+ var requestOffset = Math.max(0, state.oldestOffset - 50);
3438
+ var requestLimit = state.oldestOffset - requestOffset;
3439
+ connection.send({
3440
+ type: 'history',
3441
+ threadName: threadName,
3442
+ limit: requestLimit,
3443
+ offset: requestOffset,
3444
+ });
3445
+ }
3446
+ });
3447
+
3283
3448
  // Input area
3284
3449
  var inputArea = document.createElement('div');
3285
3450
  inputArea.className = 'cumulus-input-area';
3286
3451
 
3452
+ // Chip tray for deferred blex interactions
3453
+ var chipTray = document.createElement('div');
3454
+ chipTray.className = 'cumulus-chip-tray';
3455
+ inputArea.appendChild(chipTray);
3456
+
3287
3457
  // Attachment strip
3288
3458
  var attachStrip = document.createElement('div');
3289
3459
  attachStrip.className = 'cumulus-attach-strip';
@@ -3291,12 +3461,6 @@
3291
3461
  attachStrip.style.display = 'none';
3292
3462
  inputArea.appendChild(attachStrip);
3293
3463
 
3294
- // Interaction chip tray (blex deferred interactions)
3295
- var chipTray = document.createElement('div');
3296
- chipTray.className = 'cumulus-chip-tray';
3297
- chipTray.setAttribute('data-testid', 'webchat-chip-tray');
3298
- inputArea.appendChild(chipTray);
3299
-
3300
3464
  // Input row
3301
3465
  var inputRow = document.createElement('div');
3302
3466
  inputRow.className = 'cumulus-input-row';
@@ -3319,6 +3483,14 @@
3319
3483
  inputEl.setAttribute('data-testid', 'webchat-input');
3320
3484
  inputEl.placeholder = 'Message ' + threadName + '\u2026';
3321
3485
  inputEl.rows = 1;
3486
+ // Restore draft from previous session on this thread
3487
+ if (state.draft) {
3488
+ inputEl.value = state.draft;
3489
+ requestAnimationFrame(function () {
3490
+ inputEl.style.height = 'auto';
3491
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
3492
+ });
3493
+ }
3322
3494
 
3323
3495
  var sendBtn = document.createElement('button');
3324
3496
  sendBtn.className = 'cumulus-send-btn';
@@ -3347,6 +3519,18 @@
3347
3519
  return lineCount > 3;
3348
3520
  }
3349
3521
 
3522
+ function isSystemMessage(content) {
3523
+ return typeof content === 'string' && content.startsWith('[System]');
3524
+ }
3525
+
3526
+ function buildSystemMsgEl(content) {
3527
+ var el = document.createElement('div');
3528
+ el.className = 'cumulus-msg system';
3529
+ el.setAttribute('data-testid', 'webchat-system-msg');
3530
+ el.textContent = content.replace(/^\[System\]\s*/, '');
3531
+ return el;
3532
+ }
3533
+
3350
3534
  function buildUserMsgEl(msg) {
3351
3535
  var el = document.createElement('div');
3352
3536
  el.className = 'cumulus-msg user' + (isWideUserMessage(msg.content) ? ' wide' : '');
@@ -3385,34 +3569,9 @@
3385
3569
  if (isStreaming) {
3386
3570
  el.innerHTML += '<span class="cumulus-cursor"></span>';
3387
3571
  }
3388
- // Render blex blocks
3389
- if (mdResult.blexBlocks.length > 0) {
3390
- if (isStreaming && typeof window.Blex !== 'undefined') {
3391
- // During streaming: show placeholders
3392
- el.querySelectorAll('[data-blex-idx]').forEach(function (ph) {
3393
- var idx = parseInt(ph.getAttribute('data-blex-idx'), 10);
3394
- var block = mdResult.blexBlocks[idx];
3395
- if (block) window.Blex.renderPlaceholder(block.type, ph);
3396
- });
3397
- } else if (!isStreaming) {
3398
- // Final render: full blex blocks with interaction wiring
3399
- // Defer rendering until the element is in the DOM (el.closest needs a parent)
3400
- var _blexBlocks = mdResult.blexBlocks;
3401
- requestAnimationFrame(function () {
3402
- var panelEl = el.closest('.cumulus-panel, .cumulus-widget, .cumulus-thread-panel');
3403
- var blexInputEl = panelEl
3404
- ? panelEl.querySelector('.cumulus-input, .cumulus-standalone-input')
3405
- : null;
3406
- var blexChipTray = panelEl
3407
- ? panelEl.querySelector('.cumulus-chip-tray')
3408
- : null;
3409
- var blexChipCtx = blexChipTray
3410
- ? { tray: blexChipTray, pending: blexChipTray._pendingChips || (blexChipTray._pendingChips = []) }
3411
- : null;
3412
- console.log('[blex-render] panelEl:', !!panelEl, 'inputEl:', !!blexInputEl, 'chipTray:', !!blexChipTray, 'chipCtx:', !!blexChipCtx, 'blocks:', _blexBlocks.length);
3413
- renderBlexBlocks(el, _blexBlocks, blexInputEl, blexChipCtx);
3414
- });
3415
- }
3572
+ // Render blex blocks — skip during streaming to avoid destroy/recreate churn
3573
+ if (!isStreaming) {
3574
+ renderBlexBlocks(el, mdResult.blexBlocks, false);
3416
3575
  }
3417
3576
  el.querySelectorAll('.code-block-copy-btn').forEach(function (btn) {
3418
3577
  btn.addEventListener('click', function () {
@@ -3436,7 +3595,7 @@
3436
3595
  }
3437
3596
 
3438
3597
  function renderPanelMessages() {
3439
- // Destroy existing blex block handles before clearing DOM
3598
+ // Destroy existing blex blocks before clearing DOM
3440
3599
  messagesEl.querySelectorAll('.cumulus-msg.assistant').forEach(function (m) {
3441
3600
  destroyBlexBlocks(m);
3442
3601
  });
@@ -3453,6 +3612,16 @@
3453
3612
  updateSelectionBar();
3454
3613
  return;
3455
3614
  }
3615
+ // Show "load older" indicator if more messages are available
3616
+ if (state.hasOlderMessages) {
3617
+ var loader = document.createElement('div');
3618
+ loader.className = 'cumulus-load-older';
3619
+ loader.setAttribute('data-testid', 'webchat-load-older');
3620
+ loader.textContent = state.loadingOlder
3621
+ ? 'Loading older messages\u2026'
3622
+ : '\u2191 Scroll up for older messages';
3623
+ messagesEl.appendChild(loader);
3624
+ }
3456
3625
  for (var i = 0; i < state.messages.length; i++) {
3457
3626
  var msg = state.messages[i];
3458
3627
  var row = document.createElement('div');
@@ -3475,7 +3644,9 @@
3475
3644
  })(msg.id);
3476
3645
  row.appendChild(cb);
3477
3646
  }
3478
- if (msg.role === 'user') {
3647
+ if (msg.role === 'user' && isSystemMessage(msg.content)) {
3648
+ row.appendChild(buildSystemMsgEl(msg.content));
3649
+ } else if (msg.role === 'user') {
3479
3650
  var agent = parseAgentMessage(msg.content);
3480
3651
  if (agent) {
3481
3652
  row.appendChild(buildAgentMsgEl(agent));
@@ -3708,51 +3879,29 @@
3708
3879
  }
3709
3880
 
3710
3881
  // ── Incremental streaming update ──
3711
- // Only updates the streaming message element avoids destroying completed blex blocks
3712
- var _streamingRow = null;
3713
- function updateStreamingOnly() {
3714
- if (!state.streaming) {
3715
- // Not streaming — just clear the streaming row reference.
3716
- // Do NOT trigger a full rebuild here — the 'done' handler already did that.
3717
- // A full rebuild would destroy blex blocks that are rendering asynchronously.
3718
- _streamingRow = null;
3719
- return;
3720
- }
3721
- if (!_streamingRow) {
3722
- // First token: do a full render to set up all messages + streaming row
3723
- renderPanelMessages();
3724
- // Grab reference to the streaming row (last row in messagesEl)
3725
- var rows = messagesEl.querySelectorAll('.cumulus-msg-row');
3726
- if (rows.length > 0) {
3727
- var lastRow = rows[rows.length - 1];
3728
- var streamingEl = lastRow.querySelector('[data-testid="webchat-streaming"]');
3729
- if (streamingEl) _streamingRow = lastRow;
3730
- }
3731
- return;
3732
- }
3733
- // Subsequent tokens: only update the streaming row's content
3734
- var el = _streamingRow.querySelector('.cumulus-msg.assistant');
3735
- if (el && state.streamBuffer) {
3736
- var mdResult = renderMarkdown(state.streamBuffer);
3737
- el.innerHTML = mdResult.html + '<span class="cumulus-cursor"></span>';
3738
- // Show blex placeholders during streaming
3739
- if (mdResult.blexBlocks.length > 0 && typeof window.Blex !== 'undefined') {
3740
- el.querySelectorAll('[data-blex-idx]').forEach(function (ph) {
3741
- var idx = parseInt(ph.getAttribute('data-blex-idx'), 10);
3742
- var block = mdResult.blexBlocks[idx];
3743
- if (block) window.Blex.renderPlaceholder(block.type, ph);
3744
- });
3882
+ // Only update the streaming row instead of rebuilding all messages.
3883
+ // This prevents blex blocks in completed messages from being destroyed/recreated on every token.
3884
+ function updateStreamOnly() {
3885
+ var streamRow = messagesEl.querySelector('[data-testid="webchat-streaming"]');
3886
+ if (streamRow) {
3887
+ var parentRow = streamRow.closest('.cumulus-msg-row') || streamRow.parentElement;
3888
+ if (parentRow) {
3889
+ var newEl = buildAssistantMsgEl(state.streamBuffer, true);
3890
+ parentRow.replaceChild(newEl, streamRow);
3745
3891
  }
3746
- } else if (el && !state.streamBuffer) {
3747
- el.innerHTML =
3748
- '<span class="cumulus-typing-dots"><span></span><span></span><span></span></span>';
3892
+ } else if (state.streaming) {
3893
+ // No streaming element yet — append one
3894
+ var row = document.createElement('div');
3895
+ row.className = 'cumulus-msg-row';
3896
+ row.appendChild(buildAssistantMsgEl(state.streamBuffer, true));
3897
+ messagesEl.appendChild(row);
3749
3898
  }
3750
3899
  scrollToBottom();
3751
3900
  }
3752
3901
 
3753
3902
  // Store references on the state so the router can call re-render
3754
3903
  state._renderMessages = renderPanelMessages;
3755
- state._updateStreamingOnly = updateStreamingOnly;
3904
+ state._updateStreamOnly = updateStreamOnly;
3756
3905
  state._updateSendBtn = updatePanelSendBtn;
3757
3906
 
3758
3907
  // Initial render (messages may already be loaded from a previous view)
@@ -3760,16 +3909,9 @@
3760
3909
 
3761
3910
  // ── Send message for this panel ──
3762
3911
  function sendPanelMessage() {
3763
- // Prepend deferred interaction chips to the message
3764
- var chipPending = chipTray._pendingChips || [];
3765
- var chipPrefix = '';
3766
- if (chipPending.length > 0) {
3767
- chipPrefix = chipPending.map(function (c) { return c.serialized; }).join('\n');
3768
- chipPending.length = 0;
3769
- chipTray.innerHTML = '';
3770
- }
3771
- var rawText = inputEl.value.trim();
3772
- var text = chipPrefix ? (chipPrefix + (rawText ? '\n' + rawText : '')) : rawText;
3912
+ // Prepend any deferred blex interaction chips to the message
3913
+ var chipText = collectAndClearChips(panel);
3914
+ var text = (chipText ? chipText + '\n' : '') + inputEl.value.trim();
3773
3915
 
3774
3916
  // ── Interjection: user sends while streaming ──
3775
3917
  // Freeze partial response, send the new message — server aborts the old stream
@@ -3806,6 +3948,7 @@
3806
3948
  state.messages.push({ role: 'user', content: displayText, attachments: attachSnapshot });
3807
3949
  inputEl.value = '';
3808
3950
  inputEl.style.height = 'auto';
3951
+ state.draft = '';
3809
3952
  state.pendingAttachments = [];
3810
3953
  renderPanelAttachStrip();
3811
3954
  state.streaming = true;
@@ -3846,6 +3989,8 @@
3846
3989
  inputEl.addEventListener('input', function () {
3847
3990
  inputEl.style.height = 'auto';
3848
3991
  inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
3992
+ // Persist draft per thread
3993
+ state.draft = inputEl.value;
3849
3994
  // Update send button label (Send vs Stop) based on input content
3850
3995
  updatePanelSendBtn();
3851
3996
  });
@@ -3887,15 +4032,15 @@
3887
4032
 
3888
4033
  // ── Re-render a visible thread panel's messages in-place ──
3889
4034
  // Called by the message router after state mutation
3890
- function refreshThreadPanel(threadName, streamingUpdate) {
4035
+ function refreshThreadPanel(threadName, streamingOnly) {
3891
4036
  var state = threadStates[threadName];
3892
4037
  if (!state) return;
3893
4038
 
3894
- // During streaming: use incremental updater (preserves completed blex blocks)
3895
- if (streamingUpdate && state._updateStreamingOnly) {
3896
- state._updateStreamingOnly();
4039
+ // During streaming, only update the streaming element (not all messages)
4040
+ // This prevents blex blocks in completed messages from being destroyed on every token
4041
+ if (streamingOnly && state._updateStreamOnly) {
4042
+ state._updateStreamOnly();
3897
4043
  } else if (state._renderMessages) {
3898
- // Full rebuild (done, new message, history load, etc.)
3899
4044
  state._renderMessages();
3900
4045
  }
3901
4046
  if (state._updateSendBtn) {
@@ -4183,12 +4328,11 @@
4183
4328
 
4184
4329
  function formatTime(ts) {
4185
4330
  var d = new Date(typeof ts === 'number' ? ts : Date.parse(ts));
4186
- var hh = String(d.getHours()).padStart(2, '0');
4187
- var mm = String(d.getMinutes()).padStart(2, '0');
4188
- var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
4189
- var mon = months[d.getMonth()];
4190
- var dd = String(d.getDate()).padStart(2, '0');
4191
- return hh + ':' + mm + ' ' + mon + '/' + dd;
4331
+ var h = d.getHours();
4332
+ var m = d.getMinutes();
4333
+ var ampm = h >= 12 ? 'PM' : 'AM';
4334
+ h = h % 12 || 12;
4335
+ return h + ':' + (m < 10 ? '0' : '') + m + ' ' + ampm;
4192
4336
  }
4193
4337
 
4194
4338
  // ── Push notification subscription ──
@@ -4383,29 +4527,81 @@
4383
4527
  case 'history':
4384
4528
  if (data.threadName) {
4385
4529
  var state = getThreadState(data.threadName);
4386
- if (data.messages && data.messages.length > 0) {
4387
- // Separate completed messages from any in-progress streaming message
4388
- var completed = [];
4389
- var streamingMsg = null;
4390
- for (var hi = 0; hi < data.messages.length; hi++) {
4391
- var hm = data.messages[hi];
4392
- if (hm.streaming) {
4393
- streamingMsg = hm;
4530
+ var total = data.total || 0;
4531
+ var responseOffset = typeof data.offset === 'number' ? data.offset : -1;
4532
+ var isPagination =
4533
+ state.loadingOlder && responseOffset >= 0 && responseOffset < state.oldestOffset;
4534
+
4535
+ if (isPagination) {
4536
+ // Prepend older messages to the existing array
4537
+ state.loadingOlder = false;
4538
+ if (data.messages && data.messages.length > 0) {
4539
+ var olderMsgs = data.messages.map(function (m) {
4540
+ return { id: m.id, role: m.role, content: m.content, timestamp: m.timestamp };
4541
+ });
4542
+ state.messages = olderMsgs.concat(state.messages);
4543
+ state.oldestOffset = responseOffset;
4544
+ state.hasOlderMessages = responseOffset > 0;
4545
+
4546
+ // Preserve scroll position: save current height, render, adjust
4547
+ var panel = document.querySelector('[data-thread-panel="' + data.threadName + '"]');
4548
+ var msgEl =
4549
+ panel && panel.querySelector('[data-thread-messages="' + data.threadName + '"]');
4550
+ if (msgEl) {
4551
+ var prevHeight = msgEl.scrollHeight;
4552
+ var prevTop = msgEl.scrollTop;
4553
+ refreshThreadPanel(data.threadName);
4554
+ // After render, adjust scroll so the same content stays in view
4555
+ requestAnimationFrame(function () {
4556
+ var newHeight = msgEl.scrollHeight;
4557
+ msgEl.scrollTop = prevTop + (newHeight - prevHeight);
4558
+ });
4559
+ } else {
4560
+ refreshThreadPanel(data.threadName);
4561
+ }
4562
+ } else {
4563
+ // No more messages — remove loader
4564
+ state.hasOlderMessages = false;
4565
+ var panel = document.querySelector('[data-thread-panel="' + data.threadName + '"]');
4566
+ var msgEl =
4567
+ panel && panel.querySelector('[data-thread-messages="' + data.threadName + '"]');
4568
+ if (msgEl) {
4569
+ var loader = msgEl.querySelector('.cumulus-load-older');
4570
+ if (loader) loader.remove();
4571
+ }
4572
+ }
4573
+ } else {
4574
+ // Initial load or full refresh — replace all messages
4575
+ if (data.messages && data.messages.length > 0) {
4576
+ var lastMsg = data.messages[data.messages.length - 1];
4577
+ if (lastMsg && lastMsg.streaming) {
4578
+ var nonStreamingMsgs = data.messages.slice(0, -1);
4579
+ state.messages = nonStreamingMsgs.map(function (m) {
4580
+ return { id: m.id, role: m.role, content: m.content, timestamp: m.timestamp };
4581
+ });
4582
+ state.streaming = true;
4583
+ state.streamBuffer = lastMsg.content;
4584
+ state.stopRequested = false;
4585
+ resetStreamingTimeout(data.threadName);
4394
4586
  } else {
4395
- completed.push({ id: hm.id, role: hm.role, content: hm.content, timestamp: hm.timestamp });
4587
+ state.messages = data.messages.map(function (m) {
4588
+ return { id: m.id, role: m.role, content: m.content, timestamp: m.timestamp };
4589
+ });
4396
4590
  }
4397
4591
  }
4398
- state.messages = completed;
4399
- // If server reports an active stream, restore streaming state
4400
- // so the widget shows the partial response with a blinking cursor
4401
- if (streamingMsg && data.streaming) {
4402
- state.streaming = true;
4403
- state.streamBuffer = streamingMsg.content || '';
4404
- resetStreamingTimeout(data.threadName);
4592
+ // Set pagination state from response
4593
+ if (responseOffset >= 0) {
4594
+ state.oldestOffset = responseOffset;
4595
+ state.hasOlderMessages = responseOffset > 0;
4596
+ } else if (total > 0 && data.messages) {
4597
+ // Fallback: estimate from total and message count
4598
+ state.oldestOffset = Math.max(0, total - data.messages.length);
4599
+ state.hasOlderMessages = state.oldestOffset > 0;
4405
4600
  }
4601
+ state.historyLoaded = true;
4602
+ state.loadingOlder = false;
4603
+ refreshThreadPanel(data.threadName);
4406
4604
  }
4407
- state.historyLoaded = true;
4408
- refreshThreadPanel(data.threadName);
4409
4605
  }
4410
4606
  break;
4411
4607
 
@@ -4422,7 +4618,7 @@
4422
4618
  resetStreamingTimeout(data.threadName);
4423
4619
  // Update lastActivity for this thread in allThreads
4424
4620
  updateThreadActivity(data.threadName);
4425
- // Incremental update: only touch the streaming element, preserve blex blocks
4621
+ // Incremental update: only touch the streaming element, preserve blex blocks in completed messages
4426
4622
  refreshThreadPanel(data.threadName, true);
4427
4623
  }
4428
4624
  break;