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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.11.1",
3
+ "version": "1.12.1",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -113,6 +113,21 @@ export function getErrorMessage(input: any): string {
113
113
  return 'Something went wrong.';
114
114
  }
115
115
 
116
+ /**
117
+ * True when the worker has already sent this pass's window again. Its stored
118
+ * error then carries `retried` (the worker's _mark_pass_retried writes it only
119
+ * AFTER the replacement pass was queued, so the flag never promises a retry that
120
+ * does not exist). The failure is real but it is not the file's outcome: the
121
+ * replacement pass is. The file's row must not go red over it, and the bubble is
122
+ * drawn muted rather than as a dead end. Same one-level unwrap as every reader
123
+ * here, so a streamed turn's failure answers the same way as a buffered one.
124
+ */
125
+ export function isRetriedFailure(input: any): boolean {
126
+ var envErr = csrEnvelopeError(input);
127
+ if (envErr !== undefined) input = envErr;
128
+ return !!input && typeof input === 'object' && input.retried === true;
129
+ }
130
+
116
131
  export function isErrorResponseBody(response: any): boolean {
117
132
  // A FAILED streamed row is an error however empty its payload turned out to
118
133
  // be: the status is the fact. See csrEnvelopeError.
@@ -5,7 +5,7 @@
5
5
  * view-specific display formatting. projectId is passed for link sanitization.
6
6
  */
7
7
  import { extractClaudeText, extractOpenAIText, INDEXING_COMPLETE_MARKER, EMPTY_INDEXING_REPLY, getChatHistory, bgIndexingQueueName } from './requests';
8
- import { isErrorResponseBody, getErrorMessage } from './errors';
8
+ import { isErrorResponseBody, getErrorMessage, isRetriedFailure } from './errors';
9
9
  import { streamRecoveryEnabled } from './config';
10
10
  import { sanitizeAttachmentLinksForHistory } from './links';
11
11
  import type { ChatMessage } from './host';
@@ -854,6 +854,10 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
854
854
  } else if (isQueued) { /* no assistant placeholder */ }
855
855
  else if (isErrorResponse) {
856
856
  var em: any = { role: 'assistant', content: getErrorMessage(response), isError: true };
857
+ // A failure the worker has since sent again is not this file's outcome
858
+ // (see isRetriedFailure): the group reader skips it when it settles the
859
+ // file's status, and the view draws it muted.
860
+ if (isRetriedFailure(response)) em.isRetried = true;
857
861
  if (item._fromBgChain) em._fromBgChain = true;
858
862
  if (item._isBgTask) em.isBackgroundTask = true;
859
863
  if (serverItemId !== undefined) em._serverItemId = serverItemId;
@@ -95,6 +95,10 @@ export interface ChatMessage {
95
95
  _dimSending?: boolean;
96
96
  isCancelled?: boolean;
97
97
  isError?: boolean;
98
+ /** With isError: the worker has already sent this pass's window again, so the
99
+ * failure is not the file's outcome. Set from the row's error payload
100
+ * (`retried`, see errors.ts isRetriedFailure); never set locally. */
101
+ isRetried?: boolean;
98
102
  isBackgroundTask?: boolean;
99
103
  /** Set on background-indexing REQUEST bubbles only (see IndexingFileRef). */
100
104
  _indexFile?: IndexingFileRef;
@@ -69,6 +69,7 @@ export { buildChatGreeting, type ChatGreetingParams, type ChatGreetingParts } fr
69
69
  // normalization, and history mapping — shared so both consumers stay identical.
70
70
  export {
71
71
  getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, isProviderApiKeyError,
72
+ isRetriedFailure,
72
73
  // The csr-poll STATUS ENVELOPE, and the provider error nested one level inside a
73
74
  // failed one. Exported because "is this an envelope or a body" is asked in two
74
75
  // places that must agree (the streamed settle, and every error reader), and it
@@ -728,9 +728,30 @@ export function buildChatDisplayList(
728
728
  grp.status = 'cancelled';
729
729
  } else {
730
730
  // The newest loaded outcome is the file's state: an early pass may have
731
- // errored and a later one succeeded.
732
- var last = grp.members[grp.members.length - 1].msg;
733
- grp.status = last.isError ? 'error' : 'done';
731
+ // errored and a later one succeeded. A failure the worker has already
732
+ // SENT AGAIN is not an outcome (its replacement pass is; see
733
+ // isRetriedFailure), so it is passed over. A run whose loaded passes are
734
+ // all such failures has no settled outcome yet: the replacement is on
735
+ // the queue by construction (the mark is written after its message went
736
+ // out), so the row stays live until it is adopted, rather than telling
737
+ // the user the file died on a page the worker is reading again.
738
+ // Both bubbles of the sent-again pass are passed over, the request as
739
+ // well as the failure: they share the row's id, and a request bubble
740
+ // is never an error, so stopping on it would read the pass as settled.
741
+ var retriedIds: { [id: string]: boolean } = {};
742
+ for (var ri = 0; ri < grp.members.length; ri++) {
743
+ var rm = grp.members[ri].msg;
744
+ if (rm.isRetried && rm._serverItemId) retriedIds[rm._serverItemId] = true;
745
+ }
746
+ var li = grp.members.length - 1;
747
+ while (li >= 0 && (grp.members[li].msg.isRetried ||
748
+ (grp.members[li].msg._serverItemId && retriedIds[grp.members[li].msg._serverItemId]))) li--;
749
+ if (li < 0) {
750
+ grp.status = 'active';
751
+ } else {
752
+ var last = grp.members[li].msg;
753
+ grp.status = last.isError ? 'error' : 'done';
754
+ }
734
755
  }
735
756
  // A group whose passes are ALL continuations began before the loaded
736
757
  // window; its earlier passes arrive when older history is paged in.
@@ -1106,8 +1106,11 @@ export class ChatSession {
1106
1106
  timers = [];
1107
1107
  };
1108
1108
  var stopBase = base && typeof base.stop === 'function' ? base.stop.bind(base) : null;
1109
+ // The wrapper's own resolve, kept so that stop() below can SETTLE it.
1110
+ var resolveRaced: ((v: any) => void) | null = null;
1109
1111
 
1110
1112
  var raced: any = new Promise(function (resolve, reject) {
1113
+ resolveRaced = resolve;
1111
1114
  base.then(function (res: any) {
1112
1115
  if (settled) return;
1113
1116
  settled = true; clearProbes(); resolve(res);
@@ -1140,10 +1143,21 @@ export class ChatSession {
1140
1143
  });
1141
1144
 
1142
1145
  // _trackPoll and the cancel path both reach for .stop, so the wrapper has to carry it.
1146
+ //
1147
+ // A stop SETTLES the wrapper, with the {status:'stopped'} shape the SDK's own
1148
+ // stop resolves with. It used to mark itself settled and then ignore the base
1149
+ // poll's stopped result in the handler above, so the wrapper never resolved at
1150
+ // all. Harmless for a queued turn (nothing awaits that chain to the end), but a
1151
+ // dispatch chain awaits THIS promise to clear state.sending and
1152
+ // pendingAgentRequests, so stopping a running turn's poll wedged every later
1153
+ // send onto the queued path for good. Resolving here, rather than waiting on
1154
+ // the base poll, also covers an older skapi-js whose poll cannot be stopped.
1143
1155
  raced.stop = function () {
1156
+ if (settled) return;
1144
1157
  settled = true;
1145
1158
  clearProbes();
1146
- if (stopBase) stopBase();
1159
+ if (stopBase) { try { stopBase(); } catch (e) { /* already gone */ } }
1160
+ if (resolveRaced) resolveRaced({ id: itemId, status: 'stopped' });
1147
1161
  };
1148
1162
  return raced;
1149
1163
  }
@@ -2559,6 +2573,13 @@ export class ChatSession {
2559
2573
  return response;
2560
2574
  })
2561
2575
  .then(function (response: any) {
2576
+ // Neither of these is an answer. A STOPPED poll is this client giving up
2577
+ // on the turn (cancelPendingReply), a CANCELLED envelope is the server
2578
+ // saying the row was cancelled from elsewhere (another tab or device).
2579
+ // Read as a provider body, both extract to nothing, and the turn was
2580
+ // stamped "No text response received from AI provider." and the cache
2581
+ // kept that as the answer to a question the user had stopped waiting on.
2582
+ if (isPollStopped(response) || self._isCancelledPollResult(response)) return { content: '', isError: false, cancelled: true };
2562
2583
  if (isErrorResponseBody(response)) return { content: getErrorMessage(response), isError: true };
2563
2584
  var answer = (params.aiPlatform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response));
2564
2585
  answer = (answer || '').trim();
@@ -2571,6 +2592,14 @@ export class ChatSession {
2571
2592
  // if the chatbox unmounted mid-request.
2572
2593
  delete self.pendingAgentRequests[params.key];
2573
2594
  if (dispatchItemId) self.historyItemPolls.delete(dispatchItemId);
2595
+ if (result.cancelled) {
2596
+ // Settle the turn as cancelled and write NO reply. When this client made
2597
+ // the cancel the bubbles are already settled (cancelPendingReply does it
2598
+ // before the stop resolves this chain) and this finds nothing to do; when
2599
+ // the cancel came from elsewhere, this is what settles them.
2600
+ if (dispatchItemId) self._settleCancelledTurn(dispatchItemId, params.key);
2601
+ return result;
2602
+ }
2574
2603
  var reply: ChatMessage = { role: 'assistant', content: result.content, isError: result.isError };
2575
2604
  if (dispatchItemId) reply._serverItemId = dispatchItemId;
2576
2605
 
@@ -3165,7 +3194,7 @@ export class ChatSession {
3165
3194
  // When it ISN'T (unmounted / another project), dispatchAgentRequest has
3166
3195
  // already replaced the pending bubble with the answer IN THE CACHE, so a
3167
3196
  // later loadChatHistory renders it.
3168
- Promise.resolve(run).catch(function () { }).then(function () {
3197
+ Promise.resolve(run).catch(function () { }).then(function (result: any) {
3169
3198
  // Clear `sending` UNCONDITIONALLY. It is session-global (it gates
3170
3199
  // isQueuedSend above, plus the platform/model pickers in the view), so
3171
3200
  // leaving it set when the user navigated to another project wedged
@@ -3175,6 +3204,12 @@ export class ChatSession {
3175
3204
  // dispatchAgentRequest has already written the answer into the cache
3176
3205
  // under `key`, so a later loadChatHistory renders it.
3177
3206
  self.state.sending = false;
3207
+ // A cancelled turn has NO reply to type. typewriteLatestReply reads the
3208
+ // latest settled assistant bubble out of the cache and types it into the
3209
+ // first pending placeholder on screen, and after a cancel that placeholder
3210
+ // belongs to the NEXT turn (the cancel promotes it), so running it here
3211
+ // would paint the previous answer under a question nobody has answered.
3212
+ if (result && result.cancelled) return;
3178
3213
  if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
3179
3214
  return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
3180
3215
  });
@@ -3385,6 +3420,14 @@ export class ChatSession {
3385
3420
 
3386
3421
  onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string, ownerKey?: string): void {
3387
3422
  if (serverId) this.historyItemPolls.delete(serverId);
3423
+ // The row was cancelled while this poll was still running (from another tab,
3424
+ // or under an SDK whose poll could not be stopped): settle the turn as
3425
+ // cancelled and write no answer. Read as a reply, the envelope below became
3426
+ // "No text response received from AI provider."
3427
+ if (this._isCancelledPollResult(response)) {
3428
+ if (serverId) this._settleCancelledTurn(serverId, ownerKey);
3429
+ return;
3430
+ }
3388
3431
  // This turn resolved while a DIFFERENT chat is on screen (the user moved to
3389
3432
  // another project mid-flight). state.messages now belongs to that chat, and
3390
3433
  // resolveQueuedUserBubble's positional fallbacks would happily hijack ITS
@@ -3579,6 +3622,186 @@ export class ChatSession {
3579
3622
  });
3580
3623
  }
3581
3624
 
3625
+ /**
3626
+ * Stop waiting for the reply to the chat turn that is running now, and move on.
3627
+ *
3628
+ * `msg` is the turn's pending ASSISTANT bubble (the dot-trail "Thinking"
3629
+ * placeholder), which is where both views draw the control. A turn's two bubbles
3630
+ * carry the same _serverItemId, so it is also how the request is found.
3631
+ * cancelQueuedMessage is the sibling for a turn still waiting in the queue and
3632
+ * takes the USER bubble; the two differ in what they are handed and in what has
3633
+ * to be unwound afterwards, not in what the server is asked.
3634
+ *
3635
+ * What ends the wait:
3636
+ * 1. csr-cancel marks the running row cancelled. The worker stops relaying and
3637
+ * never stores an answer for it, so the reply cannot turn up later on this
3638
+ * device or another, and history replays the turn as cancelled;
3639
+ * 2. the poll on it is stopped, which settles whichever chain was awaiting it
3640
+ * with a stopped result: dispatchAgentRequest's for an immediate send, the
3641
+ * queued chain for a promoted turn, the history poll after a reload. Each
3642
+ * reads a stopped or cancelled result as "no reply" and releases what it
3643
+ * holds (state.sending, pendingAgentRequests, historyItemPolls), which is
3644
+ * what lets the next send go out immediately instead of queueing;
3645
+ * 3. the bubbles settle at once, without waiting for that chain: the user
3646
+ * bubble takes its cancelled form, the placeholder goes, and the next queued
3647
+ * turn, if there is one, is promoted so its own "Thinking" appears.
3648
+ *
3649
+ * What it does NOT do: the provider call already in flight on the worker runs
3650
+ * to its end (a streamed one stops within about a second). A message sent
3651
+ * behind it on the same queue starts once the worker acks the cancelled row.
3652
+ *
3653
+ * Refused without a server id (the ack has not come back yet; the views disable
3654
+ * the control until it has) and while a cancel is already out. Indexing passes
3655
+ * are not this method's business: their Stop is cancelIndexingGroup, which ends
3656
+ * the file's whole chain.
3657
+ */
3658
+ cancelPendingReply(msg: ChatMessage, idx: number): void {
3659
+ var self = this;
3660
+ var id = this.host.getIdentity();
3661
+ var serverId = msg._serverItemId;
3662
+ if (!serverId || msg._cancelling) return;
3663
+ if (msg.role !== 'assistant' || !msg.isPending || msg.isBackgroundTask) return;
3664
+ var platform = id.platform;
3665
+ if (platform !== 'claude' && platform !== 'openai') return;
3666
+ var url = platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
3667
+ var queueBase = id.userId || id.projectId;
3668
+ var queue = msg._useBgQueue ? bgIndexingQueueName(queueBase) : queueBase;
3669
+ var findPlaceholder = function () {
3670
+ return self.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.role === 'assistant' && !!m.isPending; });
3671
+ };
3672
+ // Same rule as cancelQueuedMessage: the rendered index is a hint, the server
3673
+ // id is the identity, and the role test keeps this off the user bubble that
3674
+ // shares the id.
3675
+ var at = (this.state.messages[idx] && this.state.messages[idx]._serverItemId === serverId && this.state.messages[idx].role === 'assistant')
3676
+ ? idx
3677
+ : findPlaceholder();
3678
+ if (at !== -1) {
3679
+ this.state.messages[at] = Object.assign({}, this.state.messages[at], { _cancelling: true, _cancelError: undefined });
3680
+ }
3681
+ this.host.notify();
3682
+ var unmark = function (errMsg?: string) {
3683
+ var ci = findPlaceholder();
3684
+ if (ci === -1) return;
3685
+ self.state.messages[ci] = Object.assign({}, self.state.messages[ci], { _cancelling: false, _cancelError: errMsg });
3686
+ self.host.notify();
3687
+ };
3688
+ Promise.resolve(this.host.cancelRequest({
3689
+ url: url, method: 'POST', id: serverId, queue: queue, service: id.projectId, owner: id.owner,
3690
+ })).then(function (result: any) {
3691
+ if (result && result.removed) {
3692
+ // Stop asking about a row that will never answer, THEN settle the
3693
+ // bubbles. The stop resolves the awaiting chain with a stopped result,
3694
+ // and that chain's own settle then finds nothing left to do.
3695
+ self._stopPoll(serverId as string);
3696
+ self._settleCancelledTurn(serverId as string);
3697
+ return;
3698
+ }
3699
+ // Not removed: the row had already finished (or was already gone), so the
3700
+ // answer is on its way through the poll that is still attached. Nothing to
3701
+ // unwind and nothing to report: the user gets the reply they were waiting
3702
+ // for, a moment late.
3703
+ unmark(undefined);
3704
+ }).catch(function (err: any) {
3705
+ unmark(err && typeof err.message === 'string' && err.message ? err.message : 'Could not stop this request.');
3706
+ });
3707
+ }
3708
+
3709
+ /** The cancelled form of a request bubble, with everything that keeps it in
3710
+ * place carried over: the file markers that hold an indexing pass in its
3711
+ * collapsed row, the chat it belongs to, its send time and its render key. */
3712
+ private _cancelledCopyOf(m: ChatMessage, itemId?: string): ChatMessage {
3713
+ var cancelled: ChatMessage = { role: 'user', content: m.content, isCancelled: true };
3714
+ if (itemId !== undefined) cancelled._serverItemId = itemId;
3715
+ else if (m._serverItemId !== undefined) cancelled._serverItemId = m._serverItemId;
3716
+ if (m.isBackgroundTask) cancelled.isBackgroundTask = true;
3717
+ if (m._indexFile) cancelled._indexFile = m._indexFile;
3718
+ if (m._useBgQueue) cancelled._useBgQueue = true;
3719
+ if (m._ownerKey !== undefined) cancelled._ownerKey = m._ownerKey;
3720
+ if (m._ts !== undefined) cancelled._ts = m._ts;
3721
+ if (m._localId !== undefined) cancelled._localId = m._localId;
3722
+ return cancelled;
3723
+ }
3724
+
3725
+ /**
3726
+ * Settle a turn as cancelled wherever it lives, and let the queue move on.
3727
+ *
3728
+ * On the chat on screen: every pending placeholder carrying the id goes (a
3729
+ * history refetch can have re-mapped the running turn into a second one), the
3730
+ * request bubble takes its cancelled form, and the next queued turn is promoted
3731
+ * so the conversation does not sit on an "(In queue)" bubble with nothing
3732
+ * running. For a chat that is NOT on screen (the user moved on while the turn
3733
+ * ran, and it was cancelled from elsewhere) the same edit is made in that chat's
3734
+ * history cache, which is what the next visit renders.
3735
+ *
3736
+ * Idempotent on purpose: a cancel this client made settles the bubbles at once,
3737
+ * and the chain that was awaiting the poll settles again a moment later when the
3738
+ * stop resolves it. The second pass finds nothing to do. Deliberately NOT
3739
+ * _removeStrayPendingAssistants: by then the placeholder on screen is the freshly
3740
+ * promoted turn's, whose user bubble is pending, and the sweep would take it.
3741
+ */
3742
+ private _settleCancelledTurn(itemId: string, ownerKey?: string): void {
3743
+ if (!itemId) return;
3744
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
3745
+ this._applyCancelToCache(ownerKey, itemId);
3746
+ return;
3747
+ }
3748
+ var changed = false, wasBg = false;
3749
+ for (var i = this.state.messages.length - 1; i >= 0; i--) {
3750
+ var m = this.state.messages[i];
3751
+ if (!m || m._serverItemId !== itemId) continue;
3752
+ if (m.role === 'assistant' && m.isPending) { this.state.messages.splice(i, 1); changed = true; continue; }
3753
+ if (m.role === 'user' && !m.isCancelled) {
3754
+ if (m.isBackgroundTask) wasBg = true;
3755
+ this.state.messages[i] = this._cancelledCopyOf(m, itemId);
3756
+ changed = true;
3757
+ }
3758
+ }
3759
+ this.cancelledServerIds.delete(itemId);
3760
+ if (!changed) return;
3761
+ if (wasBg) this.promoteNextBgQueuedToRunning(); else this.promoteNextQueuedToRunning();
3762
+ this.updateHistoryCache();
3763
+ this.host.notify();
3764
+ }
3765
+
3766
+ /**
3767
+ * _settleCancelledTurn for a chat that is not on screen: the same edit, made in
3768
+ * its history cache. Matched by id where the cached bubbles carry one; an
3769
+ * off-chat send caches its pair before the ack (see dispatchComposedMessage), so
3770
+ * the fallback is positional, exactly as _applyReplyToCache's is: the trailing
3771
+ * pending placeholder, and the last request bubble still marked in flight.
3772
+ */
3773
+ private _applyCancelToCache(key: string, itemId: string): void {
3774
+ var existing = key ? this.aiChatHistoryCache[key] : undefined;
3775
+ if (!existing) return;
3776
+ var msgs = existing.messages.slice();
3777
+ var changed = false;
3778
+ var pIdx = -1, uIdx = -1;
3779
+ for (var i = msgs.length - 1; i >= 0; i--) {
3780
+ var m = msgs[i];
3781
+ if (!m || m._serverItemId !== itemId) continue;
3782
+ if (m.role === 'assistant' && m.isPending) { msgs.splice(i, 1); changed = true; pIdx = i; }
3783
+ else if (m.role === 'user' && !m.isCancelled && uIdx === -1) uIdx = i;
3784
+ }
3785
+ if (pIdx === -1) {
3786
+ for (var p = msgs.length - 1; p >= 0; p--) {
3787
+ var pm = msgs[p];
3788
+ if (!pm || !pm.isPending || pm.role !== 'assistant' || pm.isBackgroundTask || pm._serverItemId !== undefined) continue;
3789
+ msgs.splice(p, 1); changed = true; break;
3790
+ }
3791
+ }
3792
+ if (uIdx === -1) {
3793
+ for (var u = msgs.length - 1; u >= 0; u--) {
3794
+ var um = msgs[u];
3795
+ if (!um || um.role !== 'user' || um.isBackgroundTask || um._serverItemId !== undefined || um._stageId) continue;
3796
+ if (!(um.isPendingQueued || um.isPendingInProcess || um.isSendingToServer)) continue;
3797
+ uIdx = u; break;
3798
+ }
3799
+ }
3800
+ if (uIdx !== -1) { msgs[uIdx] = this._cancelledCopyOf(msgs[uIdx], itemId); changed = true; }
3801
+ if (!changed) return;
3802
+ this.aiChatHistoryCache[key] = { messages: msgs, endOfList: existing.endOfList, startKeyHistory: existing.startKeyHistory };
3803
+ }
3804
+
3582
3805
  /**
3583
3806
  * Stop indexing a file, from its collapsed row — every pass at once, not just
3584
3807
  * the bubble the user happens to see.
@@ -3679,6 +3902,16 @@ export class ChatSession {
3679
3902
  // server-cancelled by _applyIndexCancellations on the next drain, which is
3680
3903
  // also what makes the worker stop enqueueing windows.
3681
3904
  this._adoptWorkerIndexingPasses(0);
3905
+ // And every pass of this file the client ALREADY knows to be live, now, not on
3906
+ // the next drain. `cancellableIds` below is the row's own list, and it leaves
3907
+ // out an in-flight pass that a later pass has already outlived - a rule written
3908
+ // for one pass at a time, which a PDF no longer is: the worker runs several
3909
+ // windows of one file at once and they settle out of order, so the row's list
3910
+ // can be empty while eight passes are running. The sweep reads the key just
3911
+ // recorded above and cancels each known pending pass once (it skips one already
3912
+ // being cancelled, so the loop below cannot cancel a bubble twice). Server-side,
3913
+ // one cancelled window stops the whole document.
3914
+ this._sweepCancelledIndexing();
3682
3915
  var ids = group.cancellableIds || [];
3683
3916
  if (!ids.length) { this.host.notify(); return; }
3684
3917
  ids.forEach(function (serverId) {
@@ -4016,9 +4249,12 @@ export class ChatSession {
4016
4249
  if (this.state.messages.some(function (m) { return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue; })) return Promise.resolve();
4017
4250
  this.state.sending = true;
4018
4251
  this.host.scrollToBottomIfSticky(true);
4019
- return Promise.resolve(pending).catch(function () { }).then(function () {
4252
+ return Promise.resolve(pending).catch(function () { }).then(function (result: any) {
4020
4253
  if (token !== self.state.gateRefreshToken) return;
4021
4254
  self.state.sending = false;
4255
+ // Same guard as the immediate-send settle: a cancelled turn has no reply
4256
+ // to type, and the placeholder on screen may be the next turn's by now.
4257
+ if (result && result.cancelled) return;
4022
4258
  return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
4023
4259
  });
4024
4260
  }
package/styles/chat.css CHANGED
@@ -83,6 +83,28 @@
83
83
  .bq-message.is-assistant:not(.is-error):not(.is-cancelled):not(.bq-index-pass) .bq-bubble:has(> .bq-loader) > .bq-loader {
84
84
  vertical-align: middle;
85
85
  }
86
+ /* The dot-trail cycles '.', '..', '...', so left to itself it is a box that
87
+ changes width three times every 1.2s: the bubble pulsed with it, and the
88
+ stop-waiting control that follows the dots moved each time, which made it hard
89
+ to hit. Inside a bubble the loader therefore RESERVES the width of its widest
90
+ frame: a hidden '...' sets the box and the animated dots are laid over it from
91
+ the left. Sized by the text itself rather than by a length, so it is right in
92
+ any font and at any letter-spacing. Scoped to bubbles on purpose: a loader that
93
+ trails a label ("Saving...") has to keep hugging its text. */
94
+ .bq-bubble > .bq-loader {
95
+ position: relative;
96
+ display: inline-block;
97
+ white-space: nowrap;
98
+ }
99
+ .bq-bubble > .bq-loader::before {
100
+ content: '...';
101
+ visibility: hidden;
102
+ }
103
+ .bq-bubble > .bq-loader::after {
104
+ position: absolute;
105
+ left: 0;
106
+ top: 0;
107
+ }
86
108
 
87
109
  .bq-message.is-error .bq-bubble {
88
110
  background: var(--bq-danger-bg);
@@ -95,6 +117,14 @@
95
117
  color: var(--bq-danger);
96
118
  opacity: 0.65;
97
119
  }
120
+ /* A failed pass the worker has already sent again: real, but not the file's
121
+ outcome (the replacement pass is), so it is told in the muted voice of a
122
+ note rather than the red of a dead end. */
123
+ .bq-message.is-error.is-retried .bq-bubble {
124
+ background: var(--bq-paper);
125
+ border-color: var(--bq-line);
126
+ color: var(--bq-muted);
127
+ }
98
128
  .bq-message.is-pending-older .bq-bubble {
99
129
  background: var(--bq-warning-bg);
100
130
  border-color: var(--bq-warning-border);
@@ -153,6 +183,35 @@
153
183
  }
154
184
  .bq-cancel-queue-btn:hover:not(.is-disabled) { background: var(--bq-warning-bg); color: var(--bq-warning); }
155
185
  .bq-cancel-queue-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
186
+ /* Stop waiting for a reply: sits INLINE after the dot-trail in a chat turn's own
187
+ "Thinking" bubble, where the bunny is inline too (:has(> .bq-loader) above).
188
+ Same face as the queued-message × but placed differently: that one floats to
189
+ its bubble's corner, this one simply follows the dots. Muted at rest so the
190
+ dots stay the thing the eye lands on; hover changes opacity only, never its
191
+ colour. Shared by both chatboxes. */
192
+ .bq-cancel-wait-btn {
193
+ display: inline-flex;
194
+ align-items: center;
195
+ justify-content: center;
196
+ vertical-align: middle;
197
+ margin-left: 0.55rem;
198
+ width: 1.1rem;
199
+ height: 1.1rem;
200
+ min-height: 0;
201
+ padding: 0;
202
+ border: 1px solid var(--bq-line);
203
+ background: transparent;
204
+ color: var(--bq-muted);
205
+ font-size: 0.85rem;
206
+ line-height: 1;
207
+ cursor: pointer;
208
+ box-shadow: none;
209
+ border-radius: 0;
210
+ flex-shrink: 0;
211
+ opacity: 0.7;
212
+ }
213
+ .bq-cancel-wait-btn:hover:not(.is-disabled) { opacity: 1; }
214
+ .bq-cancel-wait-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
156
215
 
157
216
  /* ---- unfinalized streamed answer (ask for it) ----------------------------
158
217
  A turn whose answer was streamed and never finalized keeps its bytes in the