bunnyquery 1.11.1 → 1.12.1

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.
package/README.md CHANGED
@@ -34,6 +34,11 @@ you can build your own chat UI on top of it. See
34
34
  across as many passes as it takes. A file's passes collapse into a single
35
35
  status row in the chat that can be expanded, and stopped: "Stop" cancels every
36
36
  queued and running pass at once and ends the continuation chain.
37
+ - **Stop waiting**: a reply that is taking too long can be given up on. The
38
+ "Thinking" bubble carries a small x that cancels the running request on the
39
+ server, marks the question as cancelled, and lets the next message go out
40
+ at once instead of queueing behind it. A message still waiting in the queue
41
+ has the same control on its own bubble.
37
42
  - **Attachment parser plugins**: register a client-side parser so the widget
38
43
  extracts text in the browser from formats the model can't otherwise read, and
39
44
  indexes it directly. See [Attachment parser plugins](#attachment-parser-plugins).
package/bunnyquery.css CHANGED
@@ -1175,6 +1175,28 @@
1175
1175
  .bq-message.is-assistant:not(.is-error):not(.is-cancelled):not(.bq-index-pass) .bq-bubble:has(> .bq-loader) > .bq-loader {
1176
1176
  vertical-align: middle;
1177
1177
  }
1178
+ /* The dot-trail cycles '.', '..', '...', so left to itself it is a box that
1179
+ changes width three times every 1.2s: the bubble pulsed with it, and the
1180
+ stop-waiting control that follows the dots moved each time, which made it hard
1181
+ to hit. Inside a bubble the loader therefore RESERVES the width of its widest
1182
+ frame: a hidden '...' sets the box and the animated dots are laid over it from
1183
+ the left. Sized by the text itself rather than by a length, so it is right in
1184
+ any font and at any letter-spacing. Scoped to bubbles on purpose: a loader that
1185
+ trails a label ("Saving...") has to keep hugging its text. */
1186
+ .bq-bubble > .bq-loader {
1187
+ position: relative;
1188
+ display: inline-block;
1189
+ white-space: nowrap;
1190
+ }
1191
+ .bq-bubble > .bq-loader::before {
1192
+ content: '...';
1193
+ visibility: hidden;
1194
+ }
1195
+ .bq-bubble > .bq-loader::after {
1196
+ position: absolute;
1197
+ left: 0;
1198
+ top: 0;
1199
+ }
1178
1200
 
1179
1201
  .bq-message.is-error .bq-bubble {
1180
1202
  background: var(--bq-danger-bg);
@@ -1187,6 +1209,14 @@
1187
1209
  color: var(--bq-danger);
1188
1210
  opacity: 0.65;
1189
1211
  }
1212
+ /* A failed pass the worker has already sent again: real, but not the file's
1213
+ outcome (the replacement pass is), so it is told in the muted voice of a
1214
+ note rather than the red of a dead end. */
1215
+ .bq-message.is-error.is-retried .bq-bubble {
1216
+ background: var(--bq-paper);
1217
+ border-color: var(--bq-line);
1218
+ color: var(--bq-muted);
1219
+ }
1190
1220
  .bq-message.is-pending-older .bq-bubble {
1191
1221
  background: var(--bq-warning-bg);
1192
1222
  border-color: var(--bq-warning-border);
@@ -1245,6 +1275,35 @@
1245
1275
  }
1246
1276
  .bq-cancel-queue-btn:hover:not(.is-disabled) { background: var(--bq-warning-bg); color: var(--bq-warning); }
1247
1277
  .bq-cancel-queue-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
1278
+ /* Stop waiting for a reply: sits INLINE after the dot-trail in a chat turn's own
1279
+ "Thinking" bubble, where the bunny is inline too (:has(> .bq-loader) above).
1280
+ Same face as the queued-message × but placed differently: that one floats to
1281
+ its bubble's corner, this one simply follows the dots. Muted at rest so the
1282
+ dots stay the thing the eye lands on; hover changes opacity only, never its
1283
+ colour. Shared by both chatboxes. */
1284
+ .bq-cancel-wait-btn {
1285
+ display: inline-flex;
1286
+ align-items: center;
1287
+ justify-content: center;
1288
+ vertical-align: middle;
1289
+ margin-left: 0.55rem;
1290
+ width: 1.1rem;
1291
+ height: 1.1rem;
1292
+ min-height: 0;
1293
+ padding: 0;
1294
+ border: 1px solid var(--bq-line);
1295
+ background: transparent;
1296
+ color: var(--bq-muted);
1297
+ font-size: 0.85rem;
1298
+ line-height: 1;
1299
+ cursor: pointer;
1300
+ box-shadow: none;
1301
+ border-radius: 0;
1302
+ flex-shrink: 0;
1303
+ opacity: 0.7;
1304
+ }
1305
+ .bq-cancel-wait-btn:hover:not(.is-disabled) { opacity: 1; }
1306
+ .bq-cancel-wait-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
1248
1307
 
1249
1308
  /* ---- unfinalized streamed answer (ask for it) ----------------------------
1250
1309
  A turn whose answer was streamed and never finalized keeps its bytes in the
package/bunnyquery.js CHANGED
@@ -619,6 +619,11 @@ Index the REMAINING windows - one record per row/item, looking at any page image
619
619
  }
620
620
  return "Something went wrong.";
621
621
  }
622
+ function isRetriedFailure(input) {
623
+ var envErr = csrEnvelopeError(input);
624
+ if (envErr !== void 0) input = envErr;
625
+ return !!input && typeof input === "object" && input.retried === true;
626
+ }
622
627
  function isErrorResponseBody(response) {
623
628
  var envErr = csrEnvelopeError(response);
624
629
  if (envErr !== void 0) response = envErr;
@@ -3154,6 +3159,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3154
3159
  mapped.push(ph);
3155
3160
  } else if (isQueued) ; else if (isErrorResponse) {
3156
3161
  var em = { role: "assistant", content: getErrorMessage(response), isError: true };
3162
+ if (isRetriedFailure(response)) em.isRetried = true;
3157
3163
  if (item._fromBgChain) em._fromBgChain = true;
3158
3164
  if (item._isBgTask) em.isBackgroundTask = true;
3159
3165
  if (serverItemId !== void 0) em._serverItemId = serverItemId;
@@ -4182,7 +4188,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4182
4188
  timers = [];
4183
4189
  };
4184
4190
  var stopBase = base && typeof base.stop === "function" ? base.stop.bind(base) : null;
4191
+ var resolveRaced = null;
4185
4192
  var raced = new Promise(function(resolve, reject) {
4193
+ resolveRaced = resolve;
4186
4194
  base.then(function(res) {
4187
4195
  if (settled) return;
4188
4196
  settled = true;
@@ -4223,9 +4231,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4223
4231
  });
4224
4232
  });
4225
4233
  raced.stop = function() {
4234
+ if (settled) return;
4226
4235
  settled = true;
4227
4236
  clearProbes();
4228
- if (stopBase) stopBase();
4237
+ if (stopBase) {
4238
+ try {
4239
+ stopBase();
4240
+ } catch (e) {
4241
+ }
4242
+ }
4243
+ if (resolveRaced) resolveRaced({ id: itemId, status: "stopped" });
4229
4244
  };
4230
4245
  return raced;
4231
4246
  }
@@ -5238,6 +5253,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5238
5253
  }
5239
5254
  return response;
5240
5255
  }).then(function(response) {
5256
+ if (isPollStopped(response) || self._isCancelledPollResult(response)) return { content: "", isError: false, cancelled: true };
5241
5257
  if (isErrorResponseBody(response)) return { content: getErrorMessage(response), isError: true };
5242
5258
  var answer = params.aiPlatform === "openai" ? extractOpenAIText(response) : extractClaudeText(response);
5243
5259
  answer = (answer || "").trim();
@@ -5247,6 +5263,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5247
5263
  }).then(function(result) {
5248
5264
  delete self.pendingAgentRequests[params.key];
5249
5265
  if (dispatchItemId) self.historyItemPolls.delete(dispatchItemId);
5266
+ if (result.cancelled) {
5267
+ if (dispatchItemId) self._settleCancelledTurn(dispatchItemId, params.key);
5268
+ return result;
5269
+ }
5250
5270
  var reply = { role: "assistant", content: result.content, isError: result.isError };
5251
5271
  if (dispatchItemId) reply._serverItemId = dispatchItemId;
5252
5272
  self._applyReplyToCache(params.key, reply, dispatchItemId);
@@ -5725,8 +5745,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5725
5745
  }
5726
5746
  });
5727
5747
  Promise.resolve(run).catch(function() {
5728
- }).then(function() {
5748
+ }).then(function(result) {
5729
5749
  self.state.sending = false;
5750
+ if (result && result.cancelled) return;
5730
5751
  if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
5731
5752
  return Promise.resolve(self.typewriteLatestReply(key)).then(function() {
5732
5753
  self.host.scrollToBottomIfSticky(true);
@@ -5905,6 +5926,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5905
5926
  }
5906
5927
  onQueuedSendResponse(_composed, response, platform, serverId, ownerKey) {
5907
5928
  if (serverId) this.historyItemPolls.delete(serverId);
5929
+ if (this._isCancelledPollResult(response)) {
5930
+ if (serverId) this._settleCancelledTurn(serverId, ownerKey);
5931
+ return;
5932
+ }
5908
5933
  if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
5909
5934
  var offReply = isErrorResponseBody(response) ? { role: "assistant", content: getErrorMessage(response), isError: true } : { role: "assistant", content: ((platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "").trim() || "No text response received from AI provider." };
5910
5935
  this._applyReplyToCache(ownerKey, offReply, serverId);
@@ -6104,6 +6129,191 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6104
6129
  }
6105
6130
  });
6106
6131
  }
6132
+ /**
6133
+ * Stop waiting for the reply to the chat turn that is running now, and move on.
6134
+ *
6135
+ * `msg` is the turn's pending ASSISTANT bubble (the dot-trail "Thinking"
6136
+ * placeholder), which is where both views draw the control. A turn's two bubbles
6137
+ * carry the same _serverItemId, so it is also how the request is found.
6138
+ * cancelQueuedMessage is the sibling for a turn still waiting in the queue and
6139
+ * takes the USER bubble; the two differ in what they are handed and in what has
6140
+ * to be unwound afterwards, not in what the server is asked.
6141
+ *
6142
+ * What ends the wait:
6143
+ * 1. csr-cancel marks the running row cancelled. The worker stops relaying and
6144
+ * never stores an answer for it, so the reply cannot turn up later on this
6145
+ * device or another, and history replays the turn as cancelled;
6146
+ * 2. the poll on it is stopped, which settles whichever chain was awaiting it
6147
+ * with a stopped result: dispatchAgentRequest's for an immediate send, the
6148
+ * queued chain for a promoted turn, the history poll after a reload. Each
6149
+ * reads a stopped or cancelled result as "no reply" and releases what it
6150
+ * holds (state.sending, pendingAgentRequests, historyItemPolls), which is
6151
+ * what lets the next send go out immediately instead of queueing;
6152
+ * 3. the bubbles settle at once, without waiting for that chain: the user
6153
+ * bubble takes its cancelled form, the placeholder goes, and the next queued
6154
+ * turn, if there is one, is promoted so its own "Thinking" appears.
6155
+ *
6156
+ * What it does NOT do: the provider call already in flight on the worker runs
6157
+ * to its end (a streamed one stops within about a second). A message sent
6158
+ * behind it on the same queue starts once the worker acks the cancelled row.
6159
+ *
6160
+ * Refused without a server id (the ack has not come back yet; the views disable
6161
+ * the control until it has) and while a cancel is already out. Indexing passes
6162
+ * are not this method's business: their Stop is cancelIndexingGroup, which ends
6163
+ * the file's whole chain.
6164
+ */
6165
+ cancelPendingReply(msg, idx) {
6166
+ var self = this;
6167
+ var id = this.host.getIdentity();
6168
+ var serverId = msg._serverItemId;
6169
+ if (!serverId || msg._cancelling) return;
6170
+ if (msg.role !== "assistant" || !msg.isPending || msg.isBackgroundTask) return;
6171
+ var platform = id.platform;
6172
+ if (platform !== "claude" && platform !== "openai") return;
6173
+ var url = platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
6174
+ var queueBase = id.userId || id.projectId;
6175
+ var queue = msg._useBgQueue ? bgIndexingQueueName(queueBase) : queueBase;
6176
+ var findPlaceholder = function() {
6177
+ return self.state.messages.findIndex(function(m) {
6178
+ return m._serverItemId === serverId && m.role === "assistant" && !!m.isPending;
6179
+ });
6180
+ };
6181
+ var at = this.state.messages[idx] && this.state.messages[idx]._serverItemId === serverId && this.state.messages[idx].role === "assistant" ? idx : findPlaceholder();
6182
+ if (at !== -1) {
6183
+ this.state.messages[at] = Object.assign({}, this.state.messages[at], { _cancelling: true, _cancelError: void 0 });
6184
+ }
6185
+ this.host.notify();
6186
+ var unmark = function(errMsg) {
6187
+ var ci = findPlaceholder();
6188
+ if (ci === -1) return;
6189
+ self.state.messages[ci] = Object.assign({}, self.state.messages[ci], { _cancelling: false, _cancelError: errMsg });
6190
+ self.host.notify();
6191
+ };
6192
+ Promise.resolve(this.host.cancelRequest({
6193
+ url,
6194
+ method: "POST",
6195
+ id: serverId,
6196
+ queue,
6197
+ service: id.projectId,
6198
+ owner: id.owner
6199
+ })).then(function(result) {
6200
+ if (result && result.removed) {
6201
+ self._stopPoll(serverId);
6202
+ self._settleCancelledTurn(serverId);
6203
+ return;
6204
+ }
6205
+ unmark(void 0);
6206
+ }).catch(function(err) {
6207
+ unmark(err && typeof err.message === "string" && err.message ? err.message : "Could not stop this request.");
6208
+ });
6209
+ }
6210
+ /** The cancelled form of a request bubble, with everything that keeps it in
6211
+ * place carried over: the file markers that hold an indexing pass in its
6212
+ * collapsed row, the chat it belongs to, its send time and its render key. */
6213
+ _cancelledCopyOf(m, itemId) {
6214
+ var cancelled = { role: "user", content: m.content, isCancelled: true };
6215
+ if (itemId !== void 0) cancelled._serverItemId = itemId;
6216
+ else if (m._serverItemId !== void 0) cancelled._serverItemId = m._serverItemId;
6217
+ if (m.isBackgroundTask) cancelled.isBackgroundTask = true;
6218
+ if (m._indexFile) cancelled._indexFile = m._indexFile;
6219
+ if (m._useBgQueue) cancelled._useBgQueue = true;
6220
+ if (m._ownerKey !== void 0) cancelled._ownerKey = m._ownerKey;
6221
+ if (m._ts !== void 0) cancelled._ts = m._ts;
6222
+ if (m._localId !== void 0) cancelled._localId = m._localId;
6223
+ return cancelled;
6224
+ }
6225
+ /**
6226
+ * Settle a turn as cancelled wherever it lives, and let the queue move on.
6227
+ *
6228
+ * On the chat on screen: every pending placeholder carrying the id goes (a
6229
+ * history refetch can have re-mapped the running turn into a second one), the
6230
+ * request bubble takes its cancelled form, and the next queued turn is promoted
6231
+ * so the conversation does not sit on an "(In queue)" bubble with nothing
6232
+ * running. For a chat that is NOT on screen (the user moved on while the turn
6233
+ * ran, and it was cancelled from elsewhere) the same edit is made in that chat's
6234
+ * history cache, which is what the next visit renders.
6235
+ *
6236
+ * Idempotent on purpose: a cancel this client made settles the bubbles at once,
6237
+ * and the chain that was awaiting the poll settles again a moment later when the
6238
+ * stop resolves it. The second pass finds nothing to do. Deliberately NOT
6239
+ * _removeStrayPendingAssistants: by then the placeholder on screen is the freshly
6240
+ * promoted turn's, whose user bubble is pending, and the sweep would take it.
6241
+ */
6242
+ _settleCancelledTurn(itemId, ownerKey) {
6243
+ if (!itemId) return;
6244
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
6245
+ this._applyCancelToCache(ownerKey, itemId);
6246
+ return;
6247
+ }
6248
+ var changed = false, wasBg = false;
6249
+ for (var i = this.state.messages.length - 1; i >= 0; i--) {
6250
+ var m = this.state.messages[i];
6251
+ if (!m || m._serverItemId !== itemId) continue;
6252
+ if (m.role === "assistant" && m.isPending) {
6253
+ this.state.messages.splice(i, 1);
6254
+ changed = true;
6255
+ continue;
6256
+ }
6257
+ if (m.role === "user" && !m.isCancelled) {
6258
+ if (m.isBackgroundTask) wasBg = true;
6259
+ this.state.messages[i] = this._cancelledCopyOf(m, itemId);
6260
+ changed = true;
6261
+ }
6262
+ }
6263
+ this.cancelledServerIds.delete(itemId);
6264
+ if (!changed) return;
6265
+ if (wasBg) this.promoteNextBgQueuedToRunning();
6266
+ else this.promoteNextQueuedToRunning();
6267
+ this.updateHistoryCache();
6268
+ this.host.notify();
6269
+ }
6270
+ /**
6271
+ * _settleCancelledTurn for a chat that is not on screen: the same edit, made in
6272
+ * its history cache. Matched by id where the cached bubbles carry one; an
6273
+ * off-chat send caches its pair before the ack (see dispatchComposedMessage), so
6274
+ * the fallback is positional, exactly as _applyReplyToCache's is: the trailing
6275
+ * pending placeholder, and the last request bubble still marked in flight.
6276
+ */
6277
+ _applyCancelToCache(key, itemId) {
6278
+ var existing = key ? this.aiChatHistoryCache[key] : void 0;
6279
+ if (!existing) return;
6280
+ var msgs = existing.messages.slice();
6281
+ var changed = false;
6282
+ var pIdx = -1, uIdx = -1;
6283
+ for (var i = msgs.length - 1; i >= 0; i--) {
6284
+ var m = msgs[i];
6285
+ if (!m || m._serverItemId !== itemId) continue;
6286
+ if (m.role === "assistant" && m.isPending) {
6287
+ msgs.splice(i, 1);
6288
+ changed = true;
6289
+ pIdx = i;
6290
+ } else if (m.role === "user" && !m.isCancelled && uIdx === -1) uIdx = i;
6291
+ }
6292
+ if (pIdx === -1) {
6293
+ for (var p = msgs.length - 1; p >= 0; p--) {
6294
+ var pm = msgs[p];
6295
+ if (!pm || !pm.isPending || pm.role !== "assistant" || pm.isBackgroundTask || pm._serverItemId !== void 0) continue;
6296
+ msgs.splice(p, 1);
6297
+ changed = true;
6298
+ break;
6299
+ }
6300
+ }
6301
+ if (uIdx === -1) {
6302
+ for (var u = msgs.length - 1; u >= 0; u--) {
6303
+ var um = msgs[u];
6304
+ if (!um || um.role !== "user" || um.isBackgroundTask || um._serverItemId !== void 0 || um._stageId) continue;
6305
+ if (!(um.isPendingQueued || um.isPendingInProcess || um.isSendingToServer)) continue;
6306
+ uIdx = u;
6307
+ break;
6308
+ }
6309
+ }
6310
+ if (uIdx !== -1) {
6311
+ msgs[uIdx] = this._cancelledCopyOf(msgs[uIdx], itemId);
6312
+ changed = true;
6313
+ }
6314
+ if (!changed) return;
6315
+ this.aiChatHistoryCache[key] = { messages: msgs, endOfList: existing.endOfList, startKeyHistory: existing.startKeyHistory };
6316
+ }
6107
6317
  /**
6108
6318
  * Stop indexing a file, from its collapsed row — every pass at once, not just
6109
6319
  * the bubble the user happens to see.
@@ -6170,6 +6380,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6170
6380
  }
6171
6381
  }
6172
6382
  this._adoptWorkerIndexingPasses(0);
6383
+ this._sweepCancelledIndexing();
6173
6384
  var ids = group.cancellableIds || [];
6174
6385
  if (!ids.length) {
6175
6386
  this.host.notify();
@@ -6452,9 +6663,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6452
6663
  this.state.sending = true;
6453
6664
  this.host.scrollToBottomIfSticky(true);
6454
6665
  return Promise.resolve(pending).catch(function() {
6455
- }).then(function() {
6666
+ }).then(function(result) {
6456
6667
  if (token !== self.state.gateRefreshToken) return;
6457
6668
  self.state.sending = false;
6669
+ if (result && result.cancelled) return;
6458
6670
  return Promise.resolve(self.typewriteLatestReply(key)).then(function() {
6459
6671
  self.host.scrollToBottomIfSticky(true);
6460
6672
  });
@@ -8083,8 +8295,19 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8083
8295
  } else if (stopped) {
8084
8296
  grp.status = "cancelled";
8085
8297
  } else {
8086
- var last = grp.members[grp.members.length - 1].msg;
8087
- grp.status = last.isError ? "error" : "done";
8298
+ var retriedIds = {};
8299
+ for (var ri = 0; ri < grp.members.length; ri++) {
8300
+ var rm = grp.members[ri].msg;
8301
+ if (rm.isRetried && rm._serverItemId) retriedIds[rm._serverItemId] = true;
8302
+ }
8303
+ var li = grp.members.length - 1;
8304
+ while (li >= 0 && (grp.members[li].msg.isRetried || grp.members[li].msg._serverItemId && retriedIds[grp.members[li].msg._serverItemId])) li--;
8305
+ if (li < 0) {
8306
+ grp.status = "active";
8307
+ } else {
8308
+ var last = grp.members[li].msg;
8309
+ grp.status = last.isError ? "error" : "done";
8310
+ }
8088
8311
  }
8089
8312
  var sawFirstPass = false;
8090
8313
  for (var pi = 0; pi < grp.members.length; pi++) {
@@ -8374,7 +8597,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8374
8597
  (function() {
8375
8598
  var MCP_PROD = "https://mcp.broadwayinc.computer";
8376
8599
  var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
8377
- var BQ_VERSION = "1.11.1" ;
8600
+ var BQ_VERSION = "1.12.1" ;
8378
8601
  var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
8379
8602
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
8380
8603
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -11657,12 +11880,29 @@ Index the REMAINING windows - one record per row/item, looking at any page image
11657
11880
  var cls = ["bq-message"];
11658
11881
  cls.push(msg.role === "user" ? "is-user" : "is-assistant");
11659
11882
  if (msg.isError) cls.push("is-error");
11883
+ if (msg.isRetried) cls.push("is-retried");
11660
11884
  if (msg.isCancelled) cls.push("is-cancelled");
11661
11885
  if (msg.isPendingQueued || msg.isPendingOlder) cls.push("is-pending-older");
11662
11886
  if (msg._dimSending || msg._cancelling) cls.push("is-sending-to-server");
11663
11887
  var bubble;
11664
11888
  if (msg.isPending && !msg._streaming || streamRecoveryPhase(msg) === "active") {
11665
11889
  bubble = h("div", { class: "bq-bubble" }, h("span", { class: "bq-loader" }));
11890
+ if (msg.role === "assistant" && !msg.isBackgroundTask && !streamRecoveryPhase(msg)) {
11891
+ var waitDisabled = !msg._serverItemId || msg._cancelling;
11892
+ var stopWaitBtn = h("button", {
11893
+ class: "bq-cancel-wait-btn" + (waitDisabled ? " is-disabled" : ""),
11894
+ type: "button",
11895
+ title: "Stop waiting for this reply",
11896
+ "aria-label": "Stop waiting for this reply",
11897
+ html: "&times;"
11898
+ });
11899
+ if (!waitDisabled) stopWaitBtn.addEventListener("click", function(e) {
11900
+ e.stopPropagation();
11901
+ session.cancelPendingReply(msg, idx);
11902
+ });
11903
+ bubble.appendChild(stopWaitBtn);
11904
+ if (msg._cancelError) bubble.appendChild(h("span", { class: "bq-cancel-error", text: msg._cancelError }));
11905
+ }
11666
11906
  } else if (streamRecoveryPhase(msg)) {
11667
11907
  var labels = streamRecoveryLabels(streamRecoveryPhase(msg));
11668
11908
  bubble = h("div", { class: "bq-bubble is-stream-recover" + (streamRecoveryPhase(msg) === "failed" ? " is-stream-failed" : "") });