@luckydraw/cumulus 1.0.7 → 1.0.8

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 (44) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/gateway/adapters/webchat.d.ts.map +1 -1
  3. package/dist/gateway/adapters/webchat.js +173 -2
  4. package/dist/gateway/adapters/webchat.js.map +1 -1
  5. package/dist/gateway/gateway-agents-mcp.js +50 -0
  6. package/dist/gateway/gateway-agents-mcp.js.map +1 -1
  7. package/dist/gateway/server.d.ts +65 -0
  8. package/dist/gateway/server.d.ts.map +1 -1
  9. package/dist/gateway/server.js +222 -14
  10. package/dist/gateway/server.js.map +1 -1
  11. package/dist/gateway/static/widget.js +581 -66
  12. package/dist/lib/config.d.ts +34 -0
  13. package/dist/lib/config.d.ts.map +1 -1
  14. package/dist/lib/config.js +43 -0
  15. package/dist/lib/config.js.map +1 -1
  16. package/dist/lib/content-store.d.ts +64 -1
  17. package/dist/lib/content-store.d.ts.map +1 -1
  18. package/dist/lib/content-store.js +161 -48
  19. package/dist/lib/content-store.js.map +1 -1
  20. package/dist/lib/embeddings.d.ts +11 -1
  21. package/dist/lib/embeddings.d.ts.map +1 -1
  22. package/dist/lib/embeddings.js +301 -40
  23. package/dist/lib/embeddings.js.map +1 -1
  24. package/dist/lib/gateway.d.ts +18 -1
  25. package/dist/lib/gateway.d.ts.map +1 -1
  26. package/dist/lib/gateway.js +37 -5
  27. package/dist/lib/gateway.js.map +1 -1
  28. package/dist/lib/retriever.d.ts +34 -2
  29. package/dist/lib/retriever.d.ts.map +1 -1
  30. package/dist/lib/retriever.js +9 -2
  31. package/dist/lib/retriever.js.map +1 -1
  32. package/dist/lib/tool-inventory.d.ts.map +1 -1
  33. package/dist/lib/tool-inventory.js +2 -0
  34. package/dist/lib/tool-inventory.js.map +1 -1
  35. package/dist/mcp/index.js +7 -1
  36. package/dist/mcp/index.js.map +1 -1
  37. package/dist/mcp/server.d.ts +4 -1
  38. package/dist/mcp/server.d.ts.map +1 -1
  39. package/dist/mcp/server.js +4 -2
  40. package/dist/mcp/server.js.map +1 -1
  41. package/dist/mcp/tool-handler.d.ts.map +1 -1
  42. package/dist/mcp/tool-handler.js +11 -0
  43. package/dist/mcp/tool-handler.js.map +1 -1
  44. package/package.json +1 -1
@@ -970,6 +970,30 @@
970
970
  '.cumulus-send-btn.stop:hover:not(:disabled) { background: #a02828; }',
971
971
  '.cumulus-send-btn.interject { background: #b8860b; color: #fff; }',
972
972
  '.cumulus-send-btn.interject:hover:not(:disabled) { background: #d4a017; }',
973
+ /* Queue (task 144): teal, deliberately NOT the interject gold — the two
974
+ are different acts and the button is the only place that says which. */
975
+ '.cumulus-send-btn.queue { background: #1f6f6b; color: #dff5f3; }',
976
+ '.cumulus-send-btn.queue:hover:not(:disabled) { background: #2a8f8a; }',
977
+
978
+ /* ── Deferred user messages (task 144) ── */
979
+ '.cumulus-msg.queued {',
980
+ ' align-self: flex-end; max-width: 80%; background: #1a2733;',
981
+ ' border: 1px dashed #3a5566; border-radius: 10px; padding: 8px 10px;',
982
+ ' color: #9fb3c4; font-size: 13px; line-height: 1.45;',
983
+ '}',
984
+ '.cumulus-queued-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }',
985
+ '.cumulus-queued-badge {',
986
+ ' font-size: 10px; font-weight: 700; letter-spacing: 0.08em; color: #7fd6cf;',
987
+ ' border: 1px solid #2a8f8a; border-radius: 3px; padding: 1px 5px;',
988
+ '}',
989
+ '.cumulus-queued-note { font-size: 11px; color: #6b8199; font-style: italic; }',
990
+ '.cumulus-queued-body { white-space: pre-wrap; word-break: break-word; }',
991
+ '.cumulus-queued-actions { display: flex; gap: 6px; margin-top: 6px; }',
992
+ '.cumulus-queued-btn {',
993
+ ' background: #22323f; border: 1px solid #3a5566; color: #9fb3c4;',
994
+ ' border-radius: 4px; padding: 2px 8px; font-size: 11px; cursor: pointer;',
995
+ '}',
996
+ '.cumulus-queued-btn:hover { background: #2d4252; color: #e0e0e0; }',
973
997
 
974
998
  /* ── Auth panel ── */
975
999
  '.cumulus-auth {',
@@ -1047,13 +1071,14 @@
1047
1071
  '}',
1048
1072
  '.cumulus-thread-item {',
1049
1073
  ' display: flex; align-items: center; gap: 8px;',
1050
- ' padding: 7px 12px;',
1074
+ ' padding: 7px 12px 7px 4px;',
1051
1075
  ' cursor: pointer;',
1052
1076
  ' font-size: 13px; color: #ccc;',
1053
1077
  ' border-left: 2px solid transparent;',
1054
1078
  ' user-select: none;',
1055
1079
  ' transition: background 0.1s ease;',
1056
1080
  '}',
1081
+ '.cumulus-thread-item.co-thread { padding-left: 20px; }',
1057
1082
  '.cumulus-thread-item:hover { background: #2a2a2a; }',
1058
1083
  '.cumulus-thread-item.selected {',
1059
1084
  ' border-left-color: #0066cc;',
@@ -1073,10 +1098,24 @@
1073
1098
  ' flex: 1; overflow: hidden;',
1074
1099
  ' text-overflow: ellipsis; white-space: nowrap;',
1075
1100
  '}',
1076
- '.cumulus-thread-count {',
1077
- ' font-size: 11px; color: #666;',
1078
- ' flex-shrink: 0;',
1101
+ /* Disclosure triangle. Always occupies its slot (even when empty) so
1102
+ sibling names stay aligned; only the with-children state is clickable. */
1103
+ '.cumulus-thread-twisty {',
1104
+ ' width: 12px; flex-shrink: 0;',
1105
+ ' font-size: 9px; color: #666; line-height: 1;',
1106
+ ' text-align: center;',
1079
1107
  '}',
1108
+ '.cumulus-thread-twisty.has-children { cursor: pointer; }',
1109
+ '.cumulus-thread-twisty.has-children:hover { color: #ccc; }',
1110
+ '.cumulus-co-thread-input {',
1111
+ ' width: 100%; box-sizing: border-box;',
1112
+ ' margin: 10px 0 6px;',
1113
+ ' padding: 7px 9px;',
1114
+ ' background: #1a1a1a; color: #e0e0e0;',
1115
+ ' border: 1px solid #444; border-radius: 4px;',
1116
+ ' font-size: 13px; font-family: inherit;',
1117
+ '}',
1118
+ '.cumulus-co-thread-input:focus { outline: none; border-color: #0066cc; }',
1080
1119
  '.cumulus-sidebar-footer {',
1081
1120
  ' padding: 8px 10px;',
1082
1121
  ' border-top: 1px solid #333;',
@@ -1822,8 +1861,11 @@
1822
1861
 
1823
1862
  /* Bigger touch targets for thread items */
1824
1863
  ' .cumulus-thread-item {',
1825
- ' padding: 12px 14px; font-size: 15px; min-height: 44px;',
1864
+ ' padding: 12px 14px 12px 6px; font-size: 15px; min-height: 44px;',
1826
1865
  ' }',
1866
+ /* A 12px triangle is not a touch target — widen it, and indent to match. */
1867
+ ' .cumulus-thread-twisty { width: 24px; font-size: 12px; }',
1868
+ ' .cumulus-thread-item.co-thread { padding-left: 30px; }',
1827
1869
  ' .cumulus-new-thread-btn { padding: 12px 10px; font-size: 15px; min-height: 44px; }',
1828
1870
  ' .cumulus-new-thread-input { padding: 12px 10px; font-size: 15px; min-height: 44px; }',
1829
1871
  ' .cumulus-sidebar-footer { padding: 10px; }',
@@ -1994,7 +2036,42 @@
1994
2036
  return AGENT_COLORS[Math.abs(hash) % AGENT_COLORS.length];
1995
2037
  }
1996
2038
 
2039
+ // A drained BATCH of agent messages (server.ts drainAgentQueue, >1 message).
2040
+ // It carries no "[sender → recipient]:" prefix, so AGENT_MSG_RE never matched
2041
+ // it and the whole batch fell through to the plain user bubble — which is
2042
+ // exactly what a batch looked like in the UI. Senders are named per entry
2043
+ // inside the body instead, so they are collected from there.
2044
+ var AGENT_BATCH_RE = /^\[While you were busy, (\d+) messages arrived\]\s*/;
2045
+ var AGENT_BATCH_SENDER_RE = /^\[From (?:agent|automated sender) "([^"]+)"\]/gm;
2046
+
2047
+ function parseAgentBatchMessage(content) {
2048
+ var match = content.match(AGENT_BATCH_RE);
2049
+ if (!match) return null;
2050
+ var body = content
2051
+ .slice(match[0].length)
2052
+ .replace(/\n*\(Review the above messages[\s\S]*$/, '')
2053
+ .trim();
2054
+
2055
+ var senders = [];
2056
+ AGENT_BATCH_SENDER_RE.lastIndex = 0;
2057
+ var m;
2058
+ while ((m = AGENT_BATCH_SENDER_RE.exec(content)) !== null) {
2059
+ if (senders.indexOf(m[1]) === -1) senders.push(m[1]);
2060
+ }
2061
+ var label = senders.length === 0 ? 'agents' : senders.slice(0, 2).join(', ');
2062
+ if (senders.length > 2) label += ' +' + (senders.length - 2);
2063
+
2064
+ return {
2065
+ sender: label,
2066
+ recipient: null,
2067
+ body: body,
2068
+ batchCount: parseInt(match[1], 10),
2069
+ };
2070
+ }
2071
+
1997
2072
  function parseAgentMessage(content) {
2073
+ var batch = parseAgentBatchMessage(content);
2074
+ if (batch) return batch;
1998
2075
  var match = content.match(AGENT_MSG_RE);
1999
2076
  if (!match) return null;
2000
2077
  var body = content
@@ -2036,7 +2113,10 @@
2036
2113
  header.appendChild(nameEl);
2037
2114
  var ctx = document.createElement('span');
2038
2115
  ctx.className = 'cumulus-agent-context';
2039
- ctx.textContent = '\u2192 ' + agent.recipient;
2116
+ // A drained batch names no recipient \u2014 it says how many arrived instead.
2117
+ ctx.textContent = agent.recipient
2118
+ ? '\u2192 ' + agent.recipient
2119
+ : agent.batchCount + ' messages while busy';
2040
2120
  header.appendChild(ctx);
2041
2121
  el.appendChild(header);
2042
2122
 
@@ -4532,6 +4612,10 @@
4532
4612
  currentSegments: [],
4533
4613
  expandedChips: {}, // msgKey → true if chip expanded
4534
4614
  promptCache: {}, // promptRef → fetched capture record (task 088)
4615
+ // Deferred user messages awaiting the current response (task 144).
4616
+ // Mirrors the server's queue — never written locally except from a
4617
+ // `queued_messages` broadcast, so two tabs cannot disagree.
4618
+ queued: [],
4535
4619
  };
4536
4620
  }
4537
4621
  return threadStates[threadName];
@@ -5038,8 +5122,10 @@
5038
5122
  var threadInfo = allThreads.find(function (t) {
5039
5123
  return t.name === name;
5040
5124
  });
5125
+ // The Active section lists open panels in panel order, so it stays
5126
+ // flat — nesting there would fight the ordering it exists to show.
5041
5127
  activeSection.appendChild(
5042
- buildThreadItem(name, true, true, undefined, threadInfo && threadInfo.projectDir)
5128
+ buildThreadItem(threadInfo || { name: name }, true, true, null)
5043
5129
  );
5044
5130
  });
5045
5131
  sidebarScroll.appendChild(activeSection);
@@ -5069,27 +5155,90 @@
5069
5155
  var sorted = allThreads.slice().sort(function (a, b) {
5070
5156
  return (b.lastActivity || 0) - (a.lastActivity || 0);
5071
5157
  });
5072
- sorted.forEach(function (t) {
5158
+ buildThreadTree(sorted, collapsedMasters).forEach(function (row) {
5073
5159
  allSection.appendChild(
5074
- buildThreadItem(
5075
- t.name,
5076
- visibleSet[t.name] || false,
5077
- false,
5078
- t.messageCount,
5079
- t.projectDir
5080
- )
5160
+ buildThreadItem(row.thread, visibleSet[row.thread.name] || false, false, row)
5081
5161
  );
5082
5162
  });
5083
5163
  }
5084
5164
  sidebarScroll.appendChild(allSection);
5085
5165
  }
5086
5166
 
5087
- function buildThreadItem(name, isSelected, isActive, messageCount, projectDir) {
5167
+ /**
5168
+ * Flatten the thread list into sidebar render rows, nesting co-threads
5169
+ * under their master (task 143).
5170
+ *
5171
+ * Membership comes from the server's `master` field, never from the name.
5172
+ * Every app's visitor threads are named `<app>-<deviceId>` and would nest
5173
+ * under a prefix rule, claiming a relationship that does not exist.
5174
+ *
5175
+ * A co-thread whose master is absent from this list (a scoped viewer, or a
5176
+ * deleted master) renders at top level. Nesting is a presentation of a real
5177
+ * relationship; it must never be the reason a thread disappears.
5178
+ *
5179
+ * Pure: no DOM, no globals. `collapsed` maps masterName -> true.
5180
+ */
5181
+ function buildThreadTree(sortedThreads, collapsed) {
5182
+ var present = {};
5183
+ var i;
5184
+ for (i = 0; i < sortedThreads.length; i++) present[sortedThreads[i].name] = true;
5185
+
5186
+ var childrenOf = {};
5187
+ for (i = 0; i < sortedThreads.length; i++) {
5188
+ var t = sortedThreads[i];
5189
+ if (t.master && present[t.master] && t.master !== t.name) {
5190
+ (childrenOf[t.master] = childrenOf[t.master] || []).push(t);
5191
+ }
5192
+ }
5193
+
5194
+ var rows = [];
5195
+ for (i = 0; i < sortedThreads.length; i++) {
5196
+ var thread = sortedThreads[i];
5197
+ if (thread.master && present[thread.master] && thread.master !== thread.name) continue;
5198
+
5199
+ var kids = childrenOf[thread.name] || [];
5200
+ var isCollapsed = kids.length > 0 && !!(collapsed || {})[thread.name];
5201
+ rows.push({
5202
+ thread: thread,
5203
+ depth: 0,
5204
+ childCount: kids.length,
5205
+ collapsed: isCollapsed,
5206
+ });
5207
+ if (isCollapsed) continue;
5208
+ for (var k = 0; k < kids.length; k++) {
5209
+ rows.push({ thread: kids[k], depth: 1, childCount: 0, collapsed: false });
5210
+ }
5211
+ }
5212
+ return rows;
5213
+ }
5214
+
5215
+ function buildThreadItem(thread, isSelected, isActive, row) {
5216
+ var name = thread.name;
5217
+ var projectDir = thread.projectDir;
5088
5218
  var item = document.createElement('div');
5089
- item.className = 'cumulus-thread-item' + (isSelected ? ' selected' : '');
5219
+ item.className =
5220
+ 'cumulus-thread-item' +
5221
+ (isSelected ? ' selected' : '') +
5222
+ (row && row.depth ? ' co-thread' : '');
5090
5223
  item.setAttribute('data-testid', 'webchat-thread-item');
5091
5224
  item.setAttribute('data-thread-name', name);
5092
5225
 
5226
+ // Disclosure triangle — occupies its slot even with no children, so the
5227
+ // names of siblings stay aligned instead of jittering by a few pixels.
5228
+ var twisty = document.createElement('span');
5229
+ twisty.className = 'cumulus-thread-twisty';
5230
+ if (row && row.childCount > 0) {
5231
+ twisty.classList.add('has-children');
5232
+ twisty.textContent = row.collapsed ? '▸' : '▾';
5233
+ twisty.setAttribute('data-testid', 'webchat-thread-twisty');
5234
+ twisty.setAttribute('title', row.collapsed ? 'Show co-threads' : 'Hide co-threads');
5235
+ twisty.addEventListener('click', function (e) {
5236
+ e.stopPropagation(); // do not also open the thread
5237
+ toggleMasterCollapsed(name);
5238
+ });
5239
+ }
5240
+ item.appendChild(twisty);
5241
+
5093
5242
  var dot = document.createElement('span');
5094
5243
  dot.className = unreadThreads[name]
5095
5244
  ? 'cumulus-thread-unread'
@@ -5098,7 +5247,13 @@
5098
5247
 
5099
5248
  var nameEl = document.createElement('span');
5100
5249
  nameEl.className = 'cumulus-thread-name';
5101
- nameEl.textContent = name;
5250
+ // A co-thread's name repeats its master's prefix; showing only the label
5251
+ // keeps the tree readable. The full name stays in the tooltip and in
5252
+ // data-thread-name, which is what everything else keys off.
5253
+ nameEl.textContent =
5254
+ row && row.depth && thread.master && name.indexOf(thread.master + '-') === 0
5255
+ ? name.slice(thread.master.length + 1)
5256
+ : name;
5102
5257
  nameEl.setAttribute('title', projectDir ? name + '\n\u{1f4c1} ' + projectDir : name);
5103
5258
  item.appendChild(nameEl);
5104
5259
 
@@ -5109,18 +5264,11 @@
5109
5264
  item.appendChild(folderEl);
5110
5265
  }
5111
5266
 
5112
- if (messageCount !== undefined && messageCount !== null) {
5113
- var countEl = document.createElement('span');
5114
- countEl.className = 'cumulus-thread-count';
5115
- countEl.textContent = messageCount;
5116
- item.appendChild(countEl);
5117
- }
5118
-
5119
5267
  // Right-click context menu
5120
5268
  item.addEventListener('contextmenu', function (e) {
5121
5269
  e.preventDefault();
5122
5270
  e.stopPropagation();
5123
- showThreadContextMenu(name, e.clientX, e.clientY);
5271
+ showThreadContextMenu(name, e.clientX, e.clientY, thread.master);
5124
5272
  });
5125
5273
 
5126
5274
  item.addEventListener('click', function (e) {
@@ -5143,7 +5291,7 @@
5143
5291
  document.removeEventListener('contextmenu', dismissContextMenu);
5144
5292
  }
5145
5293
 
5146
- function showThreadContextMenu(threadName, x, y) {
5294
+ function showThreadContextMenu(threadName, x, y, master) {
5147
5295
  dismissContextMenu();
5148
5296
 
5149
5297
  var menu = document.createElement('div');
@@ -5151,6 +5299,22 @@
5151
5299
  menu.style.left = x + 'px';
5152
5300
  menu.style.top = y + 'px';
5153
5301
 
5302
+ // Co-threads are created from the master ONLY (task 143), so the item is
5303
+ // absent rather than disabled on a thread that is itself a co-thread —
5304
+ // offering an action the server answers with 409 is worse than not
5305
+ // offering it. The server still enforces; this only avoids the dead end.
5306
+ if (!master) {
5307
+ var coItem = document.createElement('button');
5308
+ coItem.className = 'cumulus-context-menu-item';
5309
+ coItem.textContent = 'New co-thread…';
5310
+ coItem.setAttribute('data-testid', 'webchat-thread-new-co-thread');
5311
+ coItem.addEventListener('click', function () {
5312
+ dismissContextMenu();
5313
+ showCoThreadDialog(threadName);
5314
+ });
5315
+ menu.appendChild(coItem);
5316
+ }
5317
+
5154
5318
  var deleteItem = document.createElement('button');
5155
5319
  deleteItem.className = 'cumulus-context-menu-item danger';
5156
5320
  deleteItem.textContent = 'Delete thread';
@@ -5177,6 +5341,163 @@
5177
5341
  }, 0);
5178
5342
  }
5179
5343
 
5344
+ // ── Co-thread creation ──
5345
+
5346
+ /**
5347
+ * Which masters are collapsed. Default is EXPANDED (absent = open) so a
5348
+ * co-thread created a moment ago is visible without a second click.
5349
+ */
5350
+ var collapsedMasters = (function () {
5351
+ try {
5352
+ return JSON.parse(localStorage.getItem('cumulus-collapsed-masters') || '{}') || {};
5353
+ } catch {
5354
+ return {};
5355
+ }
5356
+ })();
5357
+
5358
+ function toggleMasterCollapsed(name) {
5359
+ if (collapsedMasters[name]) delete collapsedMasters[name];
5360
+ else collapsedMasters[name] = true;
5361
+ try {
5362
+ localStorage.setItem('cumulus-collapsed-masters', JSON.stringify(collapsedMasters));
5363
+ } catch {
5364
+ /* private mode — collapse still works, it just does not persist */
5365
+ }
5366
+ renderSidebar();
5367
+ }
5368
+
5369
+ function showCoThreadDialog(masterName) {
5370
+ var existing = document.querySelector('.cumulus-confirm-dialog[data-context="co-thread"]');
5371
+ if (existing) existing.remove();
5372
+
5373
+ var dialog = document.createElement('div');
5374
+ dialog.className = 'cumulus-confirm-dialog';
5375
+ dialog.setAttribute('data-context', 'co-thread');
5376
+ dialog.style.cssText =
5377
+ 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 200; display: flex; align-items: center; justify-content: center;';
5378
+
5379
+ var box = document.createElement('div');
5380
+ box.className = 'cumulus-confirm-dialog-box';
5381
+
5382
+ var title = document.createElement('div');
5383
+ title.className = 'cumulus-confirm-dialog-title';
5384
+ title.textContent = 'New co-thread';
5385
+ box.appendChild(title);
5386
+
5387
+ var text = document.createElement('div');
5388
+ text.className = 'cumulus-confirm-dialog-text';
5389
+ text.textContent =
5390
+ 'A co-thread shares "' +
5391
+ masterName +
5392
+ '" memory but keeps its own conversation, so both can work at once.';
5393
+ box.appendChild(text);
5394
+
5395
+ var input = document.createElement('input');
5396
+ input.type = 'text';
5397
+ input.className = 'cumulus-co-thread-input';
5398
+ input.placeholder = 'frontend';
5399
+ input.setAttribute('data-testid', 'webchat-co-thread-label');
5400
+ box.appendChild(input);
5401
+
5402
+ var preview = document.createElement('div');
5403
+ preview.className = 'cumulus-confirm-dialog-warn';
5404
+ preview.setAttribute('data-testid', 'webchat-co-thread-preview');
5405
+ box.appendChild(preview);
5406
+
5407
+ // Mirrors CO_THREAD_LABEL in server.ts. The server is authoritative; this
5408
+ // only turns a 400 into a message written before the request is sent.
5409
+ var LABEL_OK = /^[a-zA-Z0-9_]+$/;
5410
+ function refreshPreview() {
5411
+ var label = input.value.trim();
5412
+ if (!label) {
5413
+ preview.textContent = 'Letters, numbers and underscores.';
5414
+ return false;
5415
+ }
5416
+ if (!LABEL_OK.test(label)) {
5417
+ preview.textContent = 'Letters, numbers and underscores only — no spaces or hyphens.';
5418
+ return false;
5419
+ }
5420
+ preview.textContent = 'Creates: ' + masterName + '-' + label;
5421
+ return true;
5422
+ }
5423
+ refreshPreview();
5424
+ input.addEventListener('input', refreshPreview);
5425
+
5426
+ var actions = document.createElement('div');
5427
+ actions.className = 'cumulus-confirm-dialog-actions';
5428
+
5429
+ var cancelBtn = document.createElement('button');
5430
+ cancelBtn.className = 'cumulus-confirm-dialog-cancel';
5431
+ cancelBtn.textContent = 'Cancel';
5432
+ cancelBtn.addEventListener('click', function () {
5433
+ dialog.remove();
5434
+ });
5435
+ actions.appendChild(cancelBtn);
5436
+
5437
+ var createBtn = document.createElement('button');
5438
+ createBtn.className = 'cumulus-confirm-dialog-confirm';
5439
+ createBtn.setAttribute('data-testid', 'webchat-co-thread-create');
5440
+ createBtn.textContent = 'Create';
5441
+ createBtn.addEventListener('click', function () {
5442
+ if (!refreshPreview()) return;
5443
+ createBtn.disabled = true;
5444
+ createBtn.textContent = 'Creating…';
5445
+ createCoThread(masterName, input.value.trim(), function (err, threadName) {
5446
+ if (err) {
5447
+ createBtn.disabled = false;
5448
+ createBtn.textContent = 'Create';
5449
+ preview.textContent = err;
5450
+ return;
5451
+ }
5452
+ dialog.remove();
5453
+ // The listing is what carries the `master` field, so refresh it
5454
+ // rather than synthesising an entry — a locally-invented row would
5455
+ // render at top level until the next real listing arrived.
5456
+ if (connection) connection.send({ type: 'threads' });
5457
+ soloThread(threadName);
5458
+ });
5459
+ });
5460
+ actions.appendChild(createBtn);
5461
+
5462
+ box.appendChild(actions);
5463
+ dialog.appendChild(box);
5464
+
5465
+ dialog.addEventListener('click', function (e) {
5466
+ if (e.target === dialog) dialog.remove();
5467
+ });
5468
+
5469
+ document.body.appendChild(dialog);
5470
+ input.focus();
5471
+ input.addEventListener('keydown', function (e) {
5472
+ if (e.key === 'Enter') createBtn.click();
5473
+ else if (e.key === 'Escape') dialog.remove();
5474
+ });
5475
+ }
5476
+
5477
+ function createCoThread(masterName, label, cb) {
5478
+ var xhr = new XMLHttpRequest();
5479
+ xhr.open('POST', '/api/thread/' + encodeURIComponent(masterName) + '/co-threads');
5480
+ xhr.setRequestHeader('Content-Type', 'application/json');
5481
+ xhr.setRequestHeader('X-API-Key', activeApiKey);
5482
+ xhr.onload = function () {
5483
+ var body = {};
5484
+ try {
5485
+ body = JSON.parse(xhr.responseText || '{}');
5486
+ } catch {
5487
+ /* fall through to the status-based message below */
5488
+ }
5489
+ if (xhr.status === 200 || xhr.status === 201) {
5490
+ cb(null, body.thread || masterName + '-' + label);
5491
+ return;
5492
+ }
5493
+ cb(body.error || 'Could not create the co-thread (' + xhr.status + ').');
5494
+ };
5495
+ xhr.onerror = function () {
5496
+ cb('Could not reach the gateway.');
5497
+ };
5498
+ xhr.send(JSON.stringify({ label: label }));
5499
+ }
5500
+
5180
5501
  // ── Thread delete confirmation ──
5181
5502
  function showThreadDeleteConfirm(threadName) {
5182
5503
  // Remove any existing confirm dialogs
@@ -5308,6 +5629,10 @@
5308
5629
  if (!state.historyLoaded && connection) {
5309
5630
  state.historyLoaded = true;
5310
5631
  connection.send({ type: 'history', threadName: name, limit: 200 });
5632
+ // Deferred messages live on the server, so a fresh tab (or one that
5633
+ // reconnected) has to ask. This also flushes a batch that was queued
5634
+ // while no client was watching the thread (task 144).
5635
+ connection.send({ type: 'queue_list', threadName: name });
5311
5636
  }
5312
5637
  }
5313
5638
 
@@ -7647,6 +7972,121 @@
7647
7972
  return chip;
7648
7973
  }
7649
7974
 
7975
+ // A message the user chose to QUEUE rather than interject with (task 144).
7976
+ // Shown in the conversation the moment it is accepted — pressing Queue and
7977
+ // seeing nothing happen reads as broken — and stays editable, because it
7978
+ // has not been said yet.
7979
+ function buildQueuedMsgEl(item) {
7980
+ var el = document.createElement('div');
7981
+ el.className = 'cumulus-msg queued';
7982
+ el.setAttribute('data-testid', 'webchat-queued-msg');
7983
+ el.setAttribute('data-queued-id', item.id);
7984
+
7985
+ var head = document.createElement('div');
7986
+ head.className = 'cumulus-queued-head';
7987
+ var badge = document.createElement('span');
7988
+ badge.className = 'cumulus-queued-badge';
7989
+ badge.textContent = 'QUEUED';
7990
+ head.appendChild(badge);
7991
+ var note = document.createElement('span');
7992
+ note.className = 'cumulus-queued-note';
7993
+ note.textContent = 'waiting — sends when this response finishes';
7994
+ head.appendChild(note);
7995
+ el.appendChild(head);
7996
+
7997
+ var body = document.createElement('div');
7998
+ body.className = 'cumulus-queued-body';
7999
+ body.textContent = item.text;
8000
+ el.appendChild(body);
8001
+
8002
+ var actions = document.createElement('div');
8003
+ actions.className = 'cumulus-queued-actions';
8004
+
8005
+ function actionBtn(label, testid, onClick) {
8006
+ var b = document.createElement('button');
8007
+ b.className = 'cumulus-queued-btn';
8008
+ b.textContent = label;
8009
+ b.setAttribute('data-testid', testid);
8010
+ b.addEventListener('click', onClick);
8011
+ actions.appendChild(b);
8012
+ return b;
8013
+ }
8014
+
8015
+ actionBtn('Send Now', 'webchat-queued-send-now', function () {
8016
+ if (connection)
8017
+ connection.send({ type: 'queue_send_now', threadName: threadName, id: item.id });
8018
+ });
8019
+ actionBtn('Edit', 'webchat-queued-edit', function () {
8020
+ showQueuedEditPopover(el, item);
8021
+ });
8022
+ actionBtn('×', 'webchat-queued-cancel', function () {
8023
+ if (connection)
8024
+ connection.send({ type: 'queue_cancel', threadName: threadName, id: item.id });
8025
+ });
8026
+
8027
+ el.appendChild(actions);
8028
+ return el;
8029
+ }
8030
+
8031
+ // Edit a queued message in place. Same shape as the annotation popover —
8032
+ // quote, textarea, Cancel/Update — because it is the same act: revising
8033
+ // something you have staged but not yet sent.
8034
+ function showQueuedEditPopover(anchorEl, item) {
8035
+ var existing = panel.querySelector('.cumulus-queued-popover');
8036
+ if (existing) existing.remove();
8037
+
8038
+ var pop = document.createElement('div');
8039
+ pop.className = 'cumulus-annotation-popover cumulus-queued-popover';
8040
+ pop.setAttribute('data-testid', 'webchat-queued-popover');
8041
+
8042
+ var panelRect = panel.getBoundingClientRect();
8043
+ var anchorRect = anchorEl.getBoundingClientRect();
8044
+ var top = anchorRect.top - panelRect.top + panel.scrollTop - 170;
8045
+ if (top < 4) top = anchorRect.bottom - panelRect.top + panel.scrollTop + 4;
8046
+ var left = anchorRect.left - panelRect.left;
8047
+ if (left + 260 > panelRect.width) left = panelRect.width - 270;
8048
+ if (left < 4) left = 4;
8049
+ pop.style.top = top + 'px';
8050
+ pop.style.left = left + 'px';
8051
+
8052
+ var ta = document.createElement('textarea');
8053
+ ta.rows = 4;
8054
+ ta.value = item.text;
8055
+ ta.setAttribute('data-testid', 'webchat-queued-edit-input');
8056
+ pop.appendChild(ta);
8057
+
8058
+ var acts = document.createElement('div');
8059
+ acts.className = 'annotation-actions';
8060
+ var cancel = document.createElement('button');
8061
+ cancel.className = 'annotation-cancel';
8062
+ cancel.textContent = 'Cancel';
8063
+ cancel.addEventListener('click', function () {
8064
+ pop.remove();
8065
+ });
8066
+ var save = document.createElement('button');
8067
+ save.className = 'annotation-submit';
8068
+ save.textContent = 'Update';
8069
+ save.setAttribute('data-testid', 'webchat-queued-edit-submit');
8070
+ save.addEventListener('click', function () {
8071
+ var next = ta.value.trim();
8072
+ pop.remove();
8073
+ if (!next || next === item.text) return;
8074
+ if (connection)
8075
+ connection.send({
8076
+ type: 'queue_edit',
8077
+ threadName: threadName,
8078
+ id: item.id,
8079
+ message: next,
8080
+ });
8081
+ });
8082
+ acts.appendChild(cancel);
8083
+ acts.appendChild(save);
8084
+ pop.appendChild(acts);
8085
+
8086
+ panel.appendChild(pop);
8087
+ ta.focus();
8088
+ }
8089
+
7650
8090
  function renderPanelMessages() {
7651
8091
  // Destroy existing blex blocks before clearing DOM
7652
8092
  messagesEl.querySelectorAll('.cumulus-msg.assistant').forEach(function (m) {
@@ -7731,6 +8171,16 @@
7731
8171
  );
7732
8172
  messagesEl.appendChild(row);
7733
8173
  }
8174
+ // Deferred messages sit below the in-flight response — they are what
8175
+ // happens next (task 144).
8176
+ if (state.queued && state.queued.length) {
8177
+ for (var qi = 0; qi < state.queued.length; qi++) {
8178
+ var qRow = document.createElement('div');
8179
+ qRow.className = 'cumulus-msg-row';
8180
+ qRow.appendChild(buildQueuedMsgEl(state.queued[qi]));
8181
+ messagesEl.appendChild(qRow);
8182
+ }
8183
+ }
7734
8184
  renderActivityBar();
7735
8185
  scrollToBottom();
7736
8186
  updateSelectionBar();
@@ -7877,15 +8327,19 @@
7877
8327
  sendBtn.classList.add('stop');
7878
8328
  sendBtn.disabled = false;
7879
8329
  } else if (state.streaming && hasInput) {
7880
- // Streaming with input — show Send (interjection mode)
7881
- sendBtn.textContent = uploading ? 'Uploading…' : 'Send';
8330
+ // Streaming with input — the button QUEUES (task 144). Return still
8331
+ // interjects, so both intents stay reachable; the label is what makes
8332
+ // which one you are about to get visible before you commit.
8333
+ sendBtn.textContent = uploading ? 'Uploading…' : 'Queue';
7882
8334
  sendBtn.classList.remove('stop');
7883
- sendBtn.classList.add('interject');
8335
+ sendBtn.classList.remove('interject');
8336
+ sendBtn.classList.add('queue');
7884
8337
  sendBtn.disabled = uploading;
7885
8338
  } else {
7886
8339
  sendBtn.textContent = uploading ? 'Uploading…' : 'Send';
7887
8340
  sendBtn.classList.remove('stop');
7888
8341
  sendBtn.classList.remove('interject');
8342
+ sendBtn.classList.remove('queue');
7889
8343
  sendBtn.disabled = uploading;
7890
8344
  }
7891
8345
  }
@@ -8036,12 +8490,69 @@
8036
8490
  // Initial render (messages may already be loaded from a previous view)
8037
8491
  renderPanelMessages();
8038
8492
 
8493
+ // Build the outgoing body + image payload from the current draft. Shared
8494
+ // by the send/interject path and the queue path (task 144) so a file
8495
+ // attachment behaves identically whichever one you use.
8496
+ function composePanelOutgoing(text) {
8497
+ var sendable = state.pendingAttachments.filter(function (a) {
8498
+ return a.isImage || a.status === 'done';
8499
+ });
8500
+ var images = sendable
8501
+ .filter(function (a) {
8502
+ return a.isImage;
8503
+ })
8504
+ .map(function (a) {
8505
+ return { mimeType: a.mimeType, base64: a.base64 };
8506
+ });
8507
+ var files = sendable.filter(function (a) {
8508
+ return !a.isImage && a.status === 'done' && a.path;
8509
+ });
8510
+ var body = text || '';
8511
+ if (files.length > 0) {
8512
+ var lines = files.map(function (a) {
8513
+ return '- ' + a.name + ' (' + formatFileSize(a.size || 0) + ') → ' + a.path;
8514
+ });
8515
+ body = 'Attached files:\n' + lines.join('\n') + (body ? '\n\n' + body : '');
8516
+ }
8517
+ return { body: body, images: images, attachments: sendable.slice() };
8518
+ }
8519
+
8520
+ // Clear the composer after a draft has been handed off.
8521
+ function clearPanelComposer() {
8522
+ inputEl.value = '';
8523
+ inputEl.style.height = 'auto';
8524
+ inputEl.style.overflowY = 'hidden';
8525
+ state.draft = '';
8526
+ state.pendingAttachments = [];
8527
+ renderPanelAttachStrip();
8528
+ }
8529
+
8039
8530
  // ── Send message for this panel ──
8040
- function sendPanelMessage() {
8531
+ // mode: 'queue' (the Queue button while streaming) or undefined/'send'
8532
+ // (Return, and the ordinary Send button) — see task 144.
8533
+ function sendPanelMessage(mode) {
8041
8534
  // Prepend any deferred blex interaction chips to the message
8042
8535
  var chipText = collectAndClearChips(panel);
8043
8536
  var text = (chipText ? chipText + '\n' : '') + inputEl.value.trim();
8044
8537
 
8538
+ // ── Queue (task 144): wait for the current response to finish ──
8539
+ if (mode === 'queue' && state.streaming) {
8540
+ if (!text && state.pendingAttachments.length === 0) return;
8541
+ if (!connection) return;
8542
+ if (hasPanelUploading()) return;
8543
+ var q = composePanelOutgoing(text);
8544
+ var qPayload = {
8545
+ type: 'queue_message',
8546
+ threadName: threadName,
8547
+ message: q.body || ' ',
8548
+ };
8549
+ if (q.images.length > 0) qPayload.images = q.images;
8550
+ connection.send(qPayload);
8551
+ clearPanelComposer();
8552
+ updatePanelSendBtn();
8553
+ return;
8554
+ }
8555
+
8045
8556
  // ── Interjection: user sends while streaming ──
8046
8557
  // Freeze partial response, send the new message — server aborts the old stream
8047
8558
  if (state.streaming) {
@@ -8094,10 +8605,8 @@
8094
8605
  if (hasPanelUploading()) return; // button is disabled while uploading
8095
8606
 
8096
8607
  // Drop attachments that failed to upload
8097
- var sendable = state.pendingAttachments.filter(function (a) {
8098
- return a.isImage || a.status === 'done';
8099
- });
8100
- var attachSnapshot = sendable.slice();
8608
+ var outgoing = composePanelOutgoing(text);
8609
+ var attachSnapshot = outgoing.attachments;
8101
8610
  var displayText = text || '(attachment)';
8102
8611
  state.messages.push({
8103
8612
  role: 'user',
@@ -8107,12 +8616,7 @@
8107
8616
  });
8108
8617
  // Sending re-pins to the bottom — the user expects to see their turn (task 094).
8109
8618
  state.pinnedToBottom = true;
8110
- inputEl.value = '';
8111
- inputEl.style.height = 'auto';
8112
- inputEl.style.overflowY = 'hidden';
8113
- state.draft = '';
8114
- state.pendingAttachments = [];
8115
- renderPanelAttachStrip();
8619
+ clearPanelComposer();
8116
8620
  state.streaming = true;
8117
8621
  state.stopRequested = false;
8118
8622
  state.streamBuffer = '';
@@ -8121,43 +8625,25 @@
8121
8625
  updatePanelSendBtn();
8122
8626
  renderPanelMessages();
8123
8627
 
8124
- var imagePayload = attachSnapshot
8125
- .filter(function (a) {
8126
- return a.isImage;
8127
- })
8128
- .map(function (a) {
8129
- return { mimeType: a.mimeType, base64: a.base64 };
8130
- });
8131
-
8132
- // Build "Attached files:" prefix for non-image uploads
8133
- var fileAttachments = attachSnapshot.filter(function (a) {
8134
- return !a.isImage && a.status === 'done' && a.path;
8135
- });
8136
- var messageBody = text || '';
8137
- if (fileAttachments.length > 0) {
8138
- var lines = fileAttachments.map(function (a) {
8139
- return '- ' + a.name + ' (' + formatFileSize(a.size || 0) + ') → ' + a.path;
8140
- });
8141
- messageBody =
8142
- 'Attached files:\n' + lines.join('\n') + (messageBody ? '\n\n' + messageBody : '');
8143
- }
8144
-
8145
8628
  var payload = {
8146
8629
  type: 'message',
8147
8630
  threadName: threadName,
8148
- message: messageBody || ' ',
8631
+ message: outgoing.body || ' ',
8149
8632
  };
8150
- if (imagePayload.length > 0) payload.images = imagePayload;
8633
+ if (outgoing.images.length > 0) payload.images = outgoing.images;
8151
8634
  connection.send(payload);
8152
8635
  }
8153
8636
 
8154
8637
  // ── Panel event listeners ──
8155
- sendBtn.addEventListener('click', sendPanelMessage);
8638
+ // The button queues while streaming; Return always interjects (task 144).
8639
+ sendBtn.addEventListener('click', function () {
8640
+ sendPanelMessage(state.streaming ? 'queue' : 'send');
8641
+ });
8156
8642
 
8157
8643
  inputEl.addEventListener('keydown', function (e) {
8158
8644
  if (e.key === 'Enter' && !e.shiftKey) {
8159
8645
  e.preventDefault();
8160
- sendPanelMessage();
8646
+ sendPanelMessage('send');
8161
8647
  }
8162
8648
  });
8163
8649
 
@@ -8989,6 +9475,35 @@
8989
9475
  }
8990
9476
  break;
8991
9477
 
9478
+ // ── Deferred user messages (task 144) ──
9479
+ // The server owns the queue, so its list is authoritative: every tab on
9480
+ // the thread renders exactly what is pending, including the one that
9481
+ // just queued it.
9482
+ case 'queued_messages':
9483
+ if (data.threadName) {
9484
+ var qState = getThreadState(data.threadName);
9485
+ qState.queued = data.messages || [];
9486
+ refreshThreadPanel(data.threadName);
9487
+ }
9488
+ break;
9489
+
9490
+ // The queue drained and is now a real turn. Nobody sent it from a
9491
+ // client, so nobody rendered the user half — do it here, or the chip
9492
+ // vanishes and the reply answers a question the transcript never shows.
9493
+ case 'queue_delivered':
9494
+ if (data.threadName) {
9495
+ var deliveredState = getThreadState(data.threadName);
9496
+ deliveredState.queued = [];
9497
+ deliveredState.messages.push({
9498
+ role: 'user',
9499
+ content: data.text,
9500
+ timestamp: Date.now(),
9501
+ });
9502
+ deliveredState.pinnedToBottom = true;
9503
+ refreshThreadPanel(data.threadName);
9504
+ }
9505
+ break;
9506
+
8992
9507
  // ── Include/Revert responses ──
8993
9508
  case 'include_list':
8994
9509
  if (data.threadName && data.files) {