bunnyquery 1.6.0 → 1.6.2

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.
@@ -24,23 +24,23 @@ import {
24
24
  notifyAgentContinueIndexing,
25
25
  INDEXING_COMPLETE_MARKER,
26
26
  MAX_INDEXING_RESUME_PASSES,
27
- MAX_VISION_RESUME_PASSES,
28
27
  extractClaudeText,
29
28
  extractOpenAIText,
30
29
  getChatHistory,
31
30
  POLL_INTERVAL,
32
31
  BG_INDEXING_QUEUE_SUFFIX,
32
+ isBgIndexingQueue,
33
33
  ANTHROPIC_MESSAGES_API_URL,
34
34
  OPENAI_RESPONSES_API_URL,
35
35
  type BgTaskEntry,
36
36
  } from './requests';
37
- import { isPagedReadFile, isImageVisionFile, RENDER_PAGES_PER_WINDOW } from './office';
37
+ import { isPagedReadFile, isImageVisionFile } from './office';
38
38
  import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
39
39
  import { buildBoundedChatMessages } from './budget';
40
40
  import { createInlineLinkRegex } from './links';
41
41
  import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
42
42
  import { parseAttachmentContent } from './attachment_parsers';
43
- import type { ChatHost, ChatState, ChatMessage } from './host';
43
+ import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
44
44
 
45
45
  function sleep(ms: number): Promise<void> {
46
46
  return new Promise(function (r) { setTimeout(r, ms); });
@@ -58,6 +58,22 @@ function nextFrame(cb: (t: number) => void): void {
58
58
  setTimeout(function () { cb(nowMs()); }, 16);
59
59
  }
60
60
 
61
+ /** A live poll registered in ChatSession.historyItemPolls. */
62
+ export type PollHandle = {
63
+ /** 'bg' = background indexing, pausable. 'fg' = a reply the user is waiting on. */
64
+ kind: 'fg' | 'bg';
65
+ /** Absent on an older skapi-js that cannot stop an attached poll. */
66
+ stop?: () => void;
67
+ };
68
+
69
+ /**
70
+ * True when a poll result came from stopPolling rather than the server. Duck-typed on
71
+ * purpose — see the note above about not importing skapi-js here.
72
+ */
73
+ function isPollStopped(res: any): boolean {
74
+ return !!res && typeof res === 'object' && res.status === 'stopped';
75
+ }
76
+
61
77
  export class ChatSession {
62
78
  host: ChatHost;
63
79
  state: ChatState;
@@ -65,7 +81,11 @@ export class ChatSession {
65
81
  cancelledServerIds: Set<string>;
66
82
  pendingAgentRequests: Record<string, Promise<any>>;
67
83
  aiChatHistoryCache: Record<string, { messages: ChatMessage[]; endOfList: boolean; startKeyHistory: string[] }>;
68
- historyItemPolls: Map<string, boolean>;
84
+ historyItemPolls: Map<string, PollHandle>;
85
+ /** Non-empty while polling is paused; keyed by reason so overlapping causes
86
+ * (view detached AND tab hidden) do not resume each other prematurely. */
87
+ private _pauseReasons: Set<string>;
88
+ private _resuming: boolean;
69
89
  private _lidSeq: number;
70
90
 
71
91
  constructor(host: ChatHost) {
@@ -89,9 +109,91 @@ export class ChatSession {
89
109
  this.pendingAgentRequests = {};
90
110
  this.aiChatHistoryCache = {};
91
111
  this.historyItemPolls = new Map();
112
+ this._pauseReasons = new Set();
113
+ this._resuming = false;
92
114
  this._lidSeq = 0;
93
115
  }
94
116
 
117
+ /**
118
+ * Register a live poll so (a) a remount dedupes against it instead of stacking a
119
+ * SECOND poll on the same item, and (b) pausePolling can stop it.
120
+ *
121
+ * `stop` comes from the SDK and may be absent on an older skapi-js, in which case the
122
+ * poll simply cannot be stopped and is left running — see pausePolling.
123
+ */
124
+ private _trackPoll(id: string, kind: 'fg' | 'bg', p: any): any {
125
+ var stop = p && typeof p.stop === 'function' ? p.stop.bind(p) : undefined;
126
+ if (!stop) {
127
+ // The SDK could not give us a handle. Almost always means an older skapi-js
128
+ // is loaded (or a stale bundler dep cache) — the poll will be unstoppable.
129
+ console.debug('[chat-engine] poll has no stop handle', { id: id, kind: kind });
130
+ }
131
+ this.historyItemPolls.set(id, { kind: kind, stop: stop });
132
+ return p;
133
+ }
134
+
135
+ /** True while any pause reason is active. */
136
+ isPollingPaused(): boolean {
137
+ return this._pauseReasons.size > 0;
138
+ }
139
+
140
+ /**
141
+ * Stop BACKGROUND polling until resumePolling. Foreground polls (a reply the user is
142
+ * waiting on) keep running deliberately: their results must still land in the history
143
+ * cache so resumePendingRequest can render them on return, otherwise a user who sends
144
+ * a message then navigates away comes back to a permanently stuck "Thinking...".
145
+ *
146
+ * Server-side work is untouched; this only stops asking about it. That is safe for
147
+ * document indexing because the worker drives that loop itself.
148
+ */
149
+ pausePolling(reason: string): void {
150
+ this._pauseReasons.add(reason || 'paused');
151
+ var self = this;
152
+ var stopped: string[] = [];
153
+ this.historyItemPolls.forEach(function (handle, id) {
154
+ if (!handle || handle.kind !== 'bg') return;
155
+ // No stop available (older SDK): LEAVE the entry in place. Deleting it would
156
+ // let a later drain attach a second, uncancellable poll on the same item.
157
+ if (typeof handle.stop !== 'function') return;
158
+ try { handle.stop(); } catch (e) { /* best effort */ }
159
+ stopped.push(id);
160
+ });
161
+ // Delete only what we actually stopped, one at a time. NEVER clear() the map:
162
+ // wholesale clearing is what previously let loadHistory attach a duplicate poll
163
+ // on a live item, producing duplicate replies and stranded "Thinking" bubbles.
164
+ stopped.forEach(function (id) { self.historyItemPolls.delete(id); });
165
+
166
+ }
167
+
168
+ /**
169
+ * Lift a pause reason WITHOUT running the reconcile. For a caller that is about to
170
+ * reload history anyway (a view remounting), letting resumePolling also reconcile
171
+ * would race that load and can double-attach.
172
+ */
173
+ clearPauseReason(reason: string): void {
174
+ this._pauseReasons.delete(reason || 'paused');
175
+ }
176
+
177
+ /**
178
+ * Clear a pause reason and, once none remain, re-attach polling and reconcile.
179
+ * Deliberately does NOT touch gateRefreshToken: bumping it would silently discard
180
+ * the results of anything still in flight across the pause.
181
+ */
182
+ resumePolling(reason: string): Promise<void> {
183
+ this._pauseReasons.delete(reason || 'paused');
184
+ if (this._pauseReasons.size > 0 || this._resuming) return Promise.resolve();
185
+ if (!this.host.isViewMounted || !this.host.isViewMounted()) return Promise.resolve();
186
+ var self = this;
187
+ this._resuming = true;
188
+ return Promise.resolve()
189
+ .then(function () {
190
+ self.drainBgTaskQueue();
191
+ return self.loadHistory(false, self.state.gateRefreshToken);
192
+ })
193
+ .catch(function (e: any) { console.error('[chat-engine] resume polling failed', e); })
194
+ .then(function () { self._resuming = false; });
195
+ }
196
+
95
197
  private _newLocalId(): string {
96
198
  this._lidSeq += 1;
97
199
  return 'lid_' + this._lidSeq;
@@ -106,18 +208,87 @@ export class ChatSession {
106
208
  updateHistoryCache(): void {
107
209
  var key = this.getHistoryCacheKey();
108
210
  if (!key) return;
211
+ // Never persist another chat's in-flight bubbles under THIS key. The
212
+ // dashboard shares one ChatSession across projects, so a turn dispatched
213
+ // in project A can still be sitting in state.messages while the identity
214
+ // (and therefore `key`) already points at project B — without this filter
215
+ // A's user + "Thinking..." bubbles get written into B's cache entry and
216
+ // replay on every later visit to B. Bubbles with no _ownerKey (server
217
+ // history, bg tasks) are always kept. Single pass: this runs on the
218
+ // typewriter hot path.
109
219
  this.aiChatHistoryCache[key] = {
110
- messages: this.state.messages.slice(),
220
+ messages: this.state.messages.filter(function (m) {
221
+ return m._ownerKey === undefined || m._ownerKey === key;
222
+ }),
111
223
  endOfList: this.state.historyEndOfList,
112
224
  startKeyHistory: this.state.historyStartKeyHistory.slice(),
113
225
  };
114
226
  }
115
227
 
116
- private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any) {
117
- var id = this.host.getIdentity();
228
+ /**
229
+ * Land a resolved reply in the history cache of a chat that is NOT currently
230
+ * visible, without touching state.messages. Mirrors the cache-only path in
231
+ * dispatchAgentRequest: REPLACE the trailing pending "Thinking..." bubble
232
+ * (append only when there is none), and settle the matching pending user
233
+ * bubble, so the cached copy never keeps a stuck "Thinking..." that a later
234
+ * cache-first load would re-render forever.
235
+ */
236
+ private _applyReplyToCache(key: string, reply: ChatMessage, serverId?: string): void {
237
+ if (!key) return;
238
+ var existing = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
239
+ var msgs = existing.messages.slice();
240
+
241
+ var thIdx = -1;
242
+ for (var i = msgs.length - 1; i >= 0; i--) {
243
+ var m = msgs[i];
244
+ if (!m || !m.isPending || m.role !== 'assistant' || m.isBackgroundTask) continue;
245
+ if (serverId && m._serverItemId && m._serverItemId !== serverId) continue;
246
+ thIdx = i; break;
247
+ }
248
+ if (thIdx !== -1) {
249
+ if (reply._serverItemId === undefined && msgs[thIdx]._serverItemId !== undefined) reply._serverItemId = msgs[thIdx]._serverItemId;
250
+ msgs[thIdx] = reply;
251
+ } else {
252
+ msgs.push(reply);
253
+ }
254
+
255
+ // Settle the user bubble this turn belongs to (first pending one, or the
256
+ // one carrying serverId) so it stops rendering as still in flight.
257
+ for (var j = 0; j < msgs.length; j++) {
258
+ var u = msgs[j];
259
+ if (!u || u.role !== 'user' || u.isBackgroundTask) continue;
260
+ if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
261
+ if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
262
+ var settled: ChatMessage = { role: 'user', content: u.content };
263
+ if (u._serverItemId !== undefined) settled._serverItemId = u._serverItemId;
264
+ if (u._ownerKey !== undefined) settled._ownerKey = u._ownerKey;
265
+ msgs[j] = settled;
266
+ break;
267
+ }
268
+
269
+ this.aiChatHistoryCache[key] = {
270
+ messages: msgs,
271
+ endOfList: existing.endOfList,
272
+ startKeyHistory: existing.startKeyHistory,
273
+ };
274
+ }
275
+
276
+ /**
277
+ * serviceId/owner are passed explicitly by every caller: a request can be
278
+ * dispatched after the user moved to another project, and re-reading the live
279
+ * identity here would silently send the turn to THAT project instead of the
280
+ * one it was composed for. Falls back to the live read only when a caller
281
+ * omits them.
282
+ */
283
+ private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any, serviceId?: string, owner?: string) {
284
+ if (serviceId === undefined || owner === undefined) {
285
+ var id = this.host.getIdentity();
286
+ if (serviceId === undefined) serviceId = id.serviceId;
287
+ if (owner === undefined) owner = id.owner;
288
+ }
118
289
  return platform === 'openai'
119
- ? callOpenAIWithPublicMcp(prompt, id.serviceId, id.owner, messages, system, model, userId, extractContent, fileUrls)
120
- : callClaudeWithPublicMcp(prompt, id.serviceId, id.owner, messages, system, model, userId, extractContent, fileUrls);
290
+ ? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls)
291
+ : callClaudeWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls);
121
292
  }
122
293
 
123
294
  dispatchAgentRequest(params: any) {
@@ -131,15 +302,16 @@ export class ChatSession {
131
302
  var dispatchItemId: string | undefined;
132
303
  var sendAndPoll = function () {
133
304
  return Promise.resolve(
134
- self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls)
305
+ self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.serviceId, params.owner)
135
306
  ).then(function (initial: any) {
136
307
  if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
137
308
  if (initial.id) {
138
309
  if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
139
310
  dispatchItemId = initial.id;
140
- self.historyItemPolls.set(initial.id, true);
141
311
  }
142
- return initial.poll({ latency: POLL_INTERVAL });
312
+ var dp = initial.poll({ latency: POLL_INTERVAL });
313
+ if (initial.id) self._trackPoll(initial.id, 'fg', dp);
314
+ return dp;
143
315
  }
144
316
  return initial;
145
317
  });
@@ -213,24 +385,73 @@ export class ChatSession {
213
385
  // composed = clean display text; composedForLlm carries office-extraction
214
386
  // placeholders for the provider only. useBgQueue routes a post-attachment turn
215
387
  // onto the "-bg" queue so it runs after indexing.
216
- dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any): void {
388
+ dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any, pinned?: PinnedDispatchContext): void {
217
389
  var self = this;
218
390
  if (!composed) return;
219
- var id = this.host.getIdentity();
391
+ // A send can be dispatched LONG after the user hit Send (attachment
392
+ // uploads are awaited first), by which time the live identity may have
393
+ // moved to another project. The caller pins the identity + system prompt
394
+ // it captured at Send time so the request still goes to the project the
395
+ // question was actually asked of. Falls back to the live read when the
396
+ // caller doesn't pin (the widget, which has only one project anyway).
397
+ var id = pinned ? pinned.identity : this.host.getIdentity();
220
398
  if (id.platform === 'none') return;
221
399
 
222
400
  var llmComposed = composedForLlm || composed;
223
401
 
224
- var isQueuedSend = useBgQueue || this.state.sending || this.state.messages.some(function (m) {
402
+ // Cache key of the chat this turn belongs to. Every locally-created
403
+ // bubble is stamped with it so a project switch (which flips
404
+ // getIdentity()/getHistoryCacheKey() to the new project) can't
405
+ // misattribute this turn's bubbles to that project.
406
+ // (platform === 'none' already returned above, so serviceId is the only gate)
407
+ var key = !id.serviceId ? '' : id.serviceId + '#' + id.platform;
408
+ // True when the pinned chat is NOT the one currently on screen. Then
409
+ // state.messages belongs to a different project and MUST NOT be touched:
410
+ // the turn is staged in the pinned chat's cache instead and shows up when
411
+ // the user navigates back to it.
412
+ var offChat = !!key && key !== this.getHistoryCacheKey();
413
+
414
+ var isQueuedSend = !offChat && (useBgQueue || this.state.sending || this.state.messages.some(function (m) {
225
415
  return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
226
- });
416
+ }));
227
417
 
228
418
  var aiPlatform = id.platform;
229
419
  var aiModel = id.model || undefined;
230
- var systemPrompt = this.host.buildSystemPrompt();
420
+ var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
231
421
  var userId = id.userId || id.serviceId;
232
422
  var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
233
423
 
424
+ if (offChat) {
425
+ // Stage the turn in the pinned chat's cache and dispatch. The
426
+ // client-side queue can't be consulted (its state is the other
427
+ // project's), but the SERVER serializes per queue name, so ordering
428
+ // within the pinned chat still holds. dispatchAgentRequest replaces
429
+ // the pending bubble in this same cache entry when the reply lands.
430
+ var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function (m) {
431
+ return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
432
+ !m.isCancelled && !m.isBackgroundTask;
433
+ });
434
+ var offBounded = buildBoundedChatMessages({
435
+ platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
436
+ history: offHistory.concat([{ role: 'user', content: llmComposed }]),
437
+ });
438
+ var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
439
+ this.aiChatHistoryCache[key] = {
440
+ messages: offExisting.messages.concat([
441
+ { role: 'user', content: composed, _ownerKey: key },
442
+ { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
443
+ ]),
444
+ endOfList: offExisting.endOfList,
445
+ startKeyHistory: offExisting.startKeyHistory,
446
+ };
447
+ this.dispatchAgentRequest({
448
+ key: key, serviceId: id.serviceId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
449
+ systemPrompt: systemPrompt, text: composed, boundedMessages: offBounded.messages, userId: chatQueue,
450
+ extractContent: extractContent, fileUrls: fileUrls,
451
+ });
452
+ return;
453
+ }
454
+
234
455
  if (isQueuedSend) {
235
456
  var resolvedHistory = this.state.messages.filter(function (m) {
236
457
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
@@ -241,15 +462,20 @@ export class ChatSession {
241
462
  history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
242
463
  });
243
464
  var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
465
+ if (key) queuedBubble._ownerKey = key;
244
466
  if (useBgQueue) queuedBubble._useBgQueue = true;
245
467
  this.state.messages.push(queuedBubble);
246
468
  this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
247
469
 
248
- var capturedComposed = composed, capturedPlatform = aiPlatform;
249
- Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls))
470
+ var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
471
+ Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner))
250
472
  .then(function (result: any) {
251
- var sendingIdx = self.state.messages.findIndex(function (m) {
252
- return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user';
473
+ // Only ack a bubble that belongs to THIS chat — the search is
474
+ // positional, so on another project it would stamp this turn's
475
+ // _serverItemId onto that project's unrelated in-flight bubble.
476
+ var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function (m) {
477
+ return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user' &&
478
+ (m._ownerKey === undefined || m._ownerKey === capturedKey);
253
479
  });
254
480
  var serverId = result && typeof result.id === 'string' ? result.id : undefined;
255
481
  if (sendingIdx >= 0) {
@@ -260,14 +486,15 @@ export class ChatSession {
260
486
  if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
261
487
  // Track this queued item's poll so a remount/refetch dedups
262
488
  // against it instead of attaching a duplicate history poll.
263
- if (serverId) self.historyItemPolls.set(serverId, true);
264
- return result.poll({ latency: POLL_INTERVAL })
265
- .then(function (res: any) { return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId); })
266
- .catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, serverId); });
489
+ var qp = result.poll({ latency: POLL_INTERVAL });
490
+ if (serverId) self._trackPoll(serverId, 'fg', qp);
491
+ return qp
492
+ .then(function (res: any) { if (isPollStopped(res)) return; return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId, capturedKey); })
493
+ .catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, serverId, capturedKey); });
267
494
  }
268
- return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId);
495
+ return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId, capturedKey);
269
496
  })
270
- .catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined); });
497
+ .catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined, capturedKey); });
271
498
  return;
272
499
  }
273
500
 
@@ -276,11 +503,10 @@ export class ChatSession {
276
503
  // view unmount), then rendered from the cache via typewriteLatestReply. A
277
504
  // later resumePendingRequest() re-renders it if the view remounted while the
278
505
  // request was still in flight.
279
- this.state.messages.push({ role: 'user', content: composed });
280
- this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true });
506
+ this.state.messages.push({ role: 'user', content: composed, ...(key ? { _ownerKey: key } : {}) });
507
+ this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
281
508
  this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
282
509
 
283
- var key = this.getHistoryCacheKey();
284
510
  var historyForLlm = this.state.messages.filter(function (m) { return !m.isCancelled && !m.isBackgroundTask; });
285
511
  if (llmComposed !== composed) {
286
512
  for (var li = historyForLlm.length - 1; li >= 0; li--) {
@@ -310,8 +536,16 @@ export class ChatSession {
310
536
  // already replaced the pending bubble with the answer IN THE CACHE, so a
311
537
  // later loadChatHistory renders it.
312
538
  Promise.resolve(run).catch(function () { }).then(function () {
313
- if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
539
+ // Clear `sending` UNCONDITIONALLY. It is session-global (it gates
540
+ // isQueuedSend above, plus the platform/model pickers in the view), so
541
+ // leaving it set when the user navigated to another project wedged
542
+ // every subsequent send in EVERY project onto the queued path and kept
543
+ // the view's user-bubble rescue arm armed forever. Only the RENDER of
544
+ // the reply stays gated on still showing this chat — when it isn't,
545
+ // dispatchAgentRequest has already written the answer into the cache
546
+ // under `key`, so a later loadChatHistory renders it.
314
547
  self.state.sending = false;
548
+ if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
315
549
  return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottom(true); });
316
550
  });
317
551
  }
@@ -325,9 +559,11 @@ export class ChatSession {
325
559
  var existing = this.state.messages[nextIdx];
326
560
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
327
561
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
562
+ if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
328
563
  this.state.messages[nextIdx] = promoted;
329
564
  var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true };
330
565
  if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
566
+ if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
331
567
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
332
568
  this.host.notify();
333
569
  }
@@ -342,6 +578,7 @@ export class ChatSession {
342
578
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
343
579
  if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
344
580
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
581
+ if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
345
582
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
346
583
  this.state.messages[nextIdx] = promoted;
347
584
  // Carry the promoted turn's _serverItemId onto the "Thinking..." placeholder
@@ -354,11 +591,16 @@ export class ChatSession {
354
591
  // unaffected.
355
592
  var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true };
356
593
  if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
594
+ if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
357
595
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
358
596
  this.host.notify();
359
597
  }
360
598
 
361
599
  resolveQueuedUserBubble(serverId?: string): number | undefined {
600
+ // The two fallbacks below match by POSITION, not identity, so they must
601
+ // never consider a bubble stamped for a different chat.
602
+ var liveKey = this.getHistoryCacheKey();
603
+ var isLocal = function (m: ChatMessage) { return m._ownerKey === undefined || m._ownerKey === liveKey; };
362
604
  var userIdx = -1;
363
605
  if (serverId) {
364
606
  userIdx = this.state.messages.findIndex(function (m) {
@@ -368,19 +610,19 @@ export class ChatSession {
368
610
  }
369
611
  if (userIdx === -1) {
370
612
  userIdx = this.state.messages.findIndex(function (m) {
371
- return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
613
+ return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
372
614
  });
373
615
  }
374
616
  if (userIdx === -1) {
375
617
  userIdx = this.state.messages.findIndex(function (m) {
376
- return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
618
+ return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
377
619
  });
378
620
  }
379
621
  if (serverId && this.cancelledServerIds.has(serverId)) {
380
622
  this.cancelledServerIds.delete(serverId);
381
623
  if (userIdx >= 0) {
382
624
  var ex = this.state.messages[userIdx];
383
- this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId };
625
+ this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...(ex._ownerKey !== undefined ? { _ownerKey: ex._ownerKey } : {}) };
384
626
  var thIdx = this.state.messages.findIndex(function (m, i) {
385
627
  return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
386
628
  });
@@ -393,6 +635,7 @@ export class ChatSession {
393
635
  var exist = this.state.messages[userIdx];
394
636
  var repl: ChatMessage = { role: 'user', content: exist.content };
395
637
  if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
638
+ if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
396
639
  this.state.messages[userIdx] = repl;
397
640
  }
398
641
  var thinkingIdx = userIdx >= 0
@@ -407,8 +650,21 @@ export class ChatSession {
407
650
  else this.state.messages.push(msg);
408
651
  }
409
652
 
410
- onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string): void {
653
+ onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string, ownerKey?: string): void {
411
654
  if (serverId) this.historyItemPolls.delete(serverId);
655
+ // This turn resolved while a DIFFERENT chat is on screen (the user moved to
656
+ // another project mid-flight). state.messages now belongs to that chat, and
657
+ // resolveQueuedUserBubble's positional fallbacks would happily hijack ITS
658
+ // pending bubble — or, finding nothing, push this answer onto its list.
659
+ // Settle in the owning chat's cache instead and leave the view untouched.
660
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
661
+ var offReply: ChatMessage = isErrorResponseBody(response)
662
+ ? { role: 'assistant', content: getErrorMessage(response), isError: true }
663
+ : { role: 'assistant', content: ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim() || 'No text response received from AI provider.' };
664
+ this._applyReplyToCache(ownerKey, offReply, serverId);
665
+ if (serverId) this.cancelledServerIds.delete(serverId);
666
+ return;
667
+ }
412
668
  var targetIdx = this.resolveQueuedUserBubble(serverId);
413
669
  if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
414
670
  if (isErrorResponseBody(response)) {
@@ -436,9 +692,19 @@ export class ChatSession {
436
692
  this.host.scrollToBottom(true);
437
693
  }
438
694
 
439
- onQueuedSendError(_composed: string, err: any, serverId?: string): void {
695
+ onQueuedSendError(_composed: string, err: any, serverId?: string, ownerKey?: string): void {
440
696
  var self = this;
441
697
  if (serverId) this.historyItemPolls.delete(serverId);
698
+ // Off-chat resolution — see onQueuedSendResponse. Settle in the owning
699
+ // chat's cache rather than mutating whatever chat is on screen now.
700
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
701
+ var isGone = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
702
+ this._applyReplyToCache(ownerKey, isGone
703
+ ? { role: 'assistant', content: 'Request was cancelled.', isError: true }
704
+ : { role: 'assistant', content: getErrorMessage(err), isError: true }, serverId);
705
+ if (serverId) this.cancelledServerIds.delete(serverId);
706
+ return;
707
+ }
442
708
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
443
709
  if (isNotExists) {
444
710
  var userIdx = serverId
@@ -800,7 +1066,11 @@ export class ChatSession {
800
1066
  }
801
1067
  this.bgTaskQueue.forEach(function (entry) {
802
1068
  if (entry.serviceId !== svcId || entry.platform !== plat) return;
803
- if (presentIds[entry.id]) return;
1069
+ // Bubble injection and poll attachment are INDEPENDENT. An entry whose bubble
1070
+ // already exists may still need a poll — that is exactly the state a paused
1071
+ // drain leaves behind, and returning early here stranded it as a permanent
1072
+ // "Thinking..." once polling resumed.
1073
+ if (!presentIds[entry.id]) {
804
1074
  var isRunning = entry.status === 'running';
805
1075
  var userBubble: ChatMessage = { role: 'user', content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex), isBackgroundTask: true, _serverItemId: entry.id };
806
1076
  if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
@@ -810,11 +1080,17 @@ export class ChatSession {
810
1080
  }
811
1081
  presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
812
1082
  self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottom(false);
813
- if (!self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
814
- self.historyItemPolls.set(entry.id, true);
1083
+ }
1084
+ if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
815
1085
  var capturedId = entry.id, capturedPlat = plat;
816
1086
  var capturedEntry = entry;
817
- entry.poll({ latency: POLL_INTERVAL }).then(function (response: any) {
1087
+ var wasStopped = false;
1088
+ var bp = entry.poll({ latency: POLL_INTERVAL });
1089
+ self._trackPoll(entry.id, 'bg', bp);
1090
+ bp.then(function (response: any) {
1091
+ // A stopped poll is not a result: leave the bubble and the queue entry
1092
+ // exactly as they were so resumePolling can re-attach.
1093
+ if (isPollStopped(response)) { wasStopped = true; return; }
818
1094
  self.handleHistoryItemResolution(capturedId, response, capturedPlat);
819
1095
  self.maybeResumeIndexing(capturedEntry, response, capturedPlat);
820
1096
  }).catch(function (err: any) {
@@ -827,6 +1103,9 @@ export class ChatSession {
827
1103
  self.host.notify(); self.updateHistoryCache();
828
1104
  }
829
1105
  }).then(function () {
1106
+ // Keep the queue entry when the poll was merely stopped, or resuming
1107
+ // would have nothing left to re-attach to.
1108
+ if (wasStopped) return;
830
1109
  var qi = self.bgTaskQueue.findIndex(function (q) { return q.id === capturedId; });
831
1110
  if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
832
1111
  });
@@ -836,30 +1115,30 @@ export class ChatSession {
836
1115
  }
837
1116
 
838
1117
  // Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
839
- // PDF) finished WITHOUT the completion marker, the agent ran out of room before reading
1118
+ // text) finished WITHOUT the completion marker, the agent ran out of room before reading
840
1119
  // the whole file - dispatch a CONTINUE pass (up to a cap) that resumes from where the
841
1120
  // already-saved records leave off. Additive + guarded so it never loops forever and
842
1121
  // never breaks the resolution path.
1122
+ //
1123
+ // VISION files (PDFs rendered to page images) are NOT resumed here: the proxy worker
1124
+ // advances their page window itself, off the renderer's true page count. Driving them
1125
+ // from the browser is what used to lose pages on long documents - the chain lived in tab
1126
+ // memory (a reload or a closed tab ended it), and it stopped whenever the model claimed
1127
+ // completion, which on an 88-page file happened at page 15. Continuing to dispatch here
1128
+ // as well would now double-index every window.
843
1129
  maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
844
1130
  var self = this;
845
1131
  try {
846
1132
  if (!entry || !entry.storagePath) return;
847
1133
  if (!isPagedReadFile(entry.filename, entry.mime)) return;
1134
+ if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop
848
1135
  if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
849
1136
  var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
850
1137
  if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
851
1138
  var pass = (entry.resumePass || 0) + 1;
852
- // Vision (rendered PDF pages) advances one window per pass, so it needs a much
853
- // higher cap than the text/grid path (which reads many windows per pass).
854
- var isVision = isImageVisionFile(entry.filename, entry.mime);
855
- var maxPasses = isVision ? MAX_VISION_RESUME_PASSES : MAX_INDEXING_RESUME_PASSES;
856
- if (pass > maxPasses) return; // give up after the cap
1139
+ if (pass > MAX_INDEXING_RESUME_PASSES) return; // give up after the cap
857
1140
  var id = this.host.getIdentity();
858
1141
  if (!id || id.platform === 'none' || id.serviceId !== entry.serviceId) return;
859
- // VISION files (PDFs) resume by ADVANCING the rendered page window: pass N reads
860
- // pages [N*WINDOW, (N+1)*WINDOW). Other paged files resume via a derived cursor
861
- // (the continue message tells the agent to resume from the furthest saved record).
862
- var renderFrom = isVision ? pass * RENDER_PAGES_PER_WINDOW : undefined;
863
1142
  notifyAgentContinueIndexing({
864
1143
  platform: id.platform as 'claude' | 'openai',
865
1144
  model: id.model,
@@ -868,7 +1147,6 @@ export class ChatSession {
868
1147
  userId: id.userId || id.serviceId,
869
1148
  serviceName: id.serviceName,
870
1149
  serviceDescription: id.serviceDescription,
871
- renderFrom: renderFrom,
872
1150
  attachment: {
873
1151
  name: entry.filename,
874
1152
  storagePath: entry.storagePath,
@@ -900,6 +1178,11 @@ export class ChatSession {
900
1178
  loadHistory(fetchMore?: boolean, token?: number): Promise<void> {
901
1179
  var self = this;
902
1180
  var id = this.host.getIdentity();
1181
+ // Key this fetch is FOR, snapshotted from the same identity read the
1182
+ // request is built from. The rescue below compares against this rather
1183
+ // than a live getHistoryCacheKey(), so a project switch mid-fetch can't
1184
+ // make another chat's in-flight bubbles look local.
1185
+ var loadKey = (!id.serviceId || id.platform === 'none') ? '' : id.serviceId + '#' + id.platform;
903
1186
  if (token === undefined) token = this.state.gateRefreshToken;
904
1187
  if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.serviceId) {
905
1188
  return Promise.resolve();
@@ -922,9 +1205,9 @@ export class ChatSession {
922
1205
  if (token !== self.state.gateRefreshToken) return;
923
1206
  var chatList = history && Array.isArray(history.list) ? history.list : [];
924
1207
  chatList.forEach(function (item: any) {
925
- if (typeof item.queue_name === 'string' && item.queue_name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX) {
1208
+ if (isBgIndexingQueue(item.queue_name)) {
926
1209
  var userText = extractLastUserTextFromRequest(item.request_body);
927
- if (typeof userText === 'string' && userText.indexOf('A new file has just been uploaded') === 0) item._isBgTask = true;
1210
+ if (typeof userText === 'string' && (userText.indexOf('A new file has just been uploaded') === 0 || userText.indexOf('CONTINUE indexing') === 0)) item._isBgTask = true;
928
1211
  else item._isOnBgQueue = true;
929
1212
  }
930
1213
  });
@@ -960,6 +1243,10 @@ export class ChatSession {
960
1243
  for (var ri = 0; ri < self.state.messages.length; ri++) {
961
1244
  var mm = self.state.messages[ri];
962
1245
  if (mm.isBackgroundTask) continue;
1246
+ // Belongs to a different chat (another project, or another
1247
+ // platform on this one) — it must not be carried onto THIS
1248
+ // chat's freshly-fetched history.
1249
+ if (mm._ownerKey !== undefined && mm._ownerKey !== loadKey) continue;
963
1250
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
964
1251
  if (!mm._serverItemId) {
965
1252
  if (mappedHasPendingAssistant) continue;
@@ -1022,11 +1309,13 @@ export class ChatSession {
1022
1309
  // dispatch never polls, so they MUST still get their poll — skipping
1023
1310
  // them was the stranded-"Thinking" bug this guard must not revive.
1024
1311
  if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
1025
- self.historyItemPolls.set(item.id, true);
1312
+ // Background indexing polls are suppressed while paused; foreground
1313
+ // replies the user is waiting on are not.
1314
+ if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
1026
1315
  var capturedId = item.id;
1027
1316
  var pp = item.poll({
1028
1317
  latency: POLL_INTERVAL,
1029
- onResponse: function (response: any) { self.handleHistoryItemResolution(capturedId, response, platform); },
1318
+ onResponse: function (response: any) { if (isPollStopped(response)) return; self.handleHistoryItemResolution(capturedId, response, platform); },
1030
1319
  onError: function (err: any) {
1031
1320
  self.historyItemPolls.delete(capturedId);
1032
1321
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
@@ -1049,6 +1338,12 @@ export class ChatSession {
1049
1338
  }
1050
1339
  },
1051
1340
  });
1341
+ // Anything on the BACKGROUND queue is pausable, not just items whose
1342
+ // prompt text we recognise as an indexing task. _isBgTask vs
1343
+ // _isOnBgQueue is a DISPLAY distinction ("Indexing: file" vs a normal
1344
+ // chat bubble); keying the pause off _isBgTask alone left every
1345
+ // bg-queue item we could not text-match polling forever.
1346
+ self._trackPoll(capturedId, (item._isBgTask || item._isOnBgQueue) ? 'bg' : 'fg', pp);
1052
1347
  if (pp && pp.catch) pp.catch(function () { });
1053
1348
  });
1054
1349
  self.drainBgTaskQueue();