bunnyquery 1.5.7 → 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.
@@ -21,21 +21,26 @@ import {
21
21
  callClaudeWithPublicMcp,
22
22
  callOpenAIWithPublicMcp,
23
23
  notifyAgentSaveAttachment,
24
+ notifyAgentContinueIndexing,
25
+ INDEXING_COMPLETE_MARKER,
26
+ MAX_INDEXING_RESUME_PASSES,
24
27
  extractClaudeText,
25
28
  extractOpenAIText,
26
29
  getChatHistory,
27
30
  POLL_INTERVAL,
28
31
  BG_INDEXING_QUEUE_SUFFIX,
32
+ isBgIndexingQueue,
29
33
  ANTHROPIC_MESSAGES_API_URL,
30
34
  OPENAI_RESPONSES_API_URL,
31
35
  type BgTaskEntry,
32
36
  } from './requests';
37
+ import { isPagedReadFile, isImageVisionFile } from './office';
33
38
  import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
34
39
  import { buildBoundedChatMessages } from './budget';
35
40
  import { createInlineLinkRegex } from './links';
36
41
  import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
37
42
  import { parseAttachmentContent } from './attachment_parsers';
38
- import type { ChatHost, ChatState, ChatMessage } from './host';
43
+ import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
39
44
 
40
45
  function sleep(ms: number): Promise<void> {
41
46
  return new Promise(function (r) { setTimeout(r, ms); });
@@ -53,6 +58,22 @@ function nextFrame(cb: (t: number) => void): void {
53
58
  setTimeout(function () { cb(nowMs()); }, 16);
54
59
  }
55
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
+
56
77
  export class ChatSession {
57
78
  host: ChatHost;
58
79
  state: ChatState;
@@ -60,7 +81,11 @@ export class ChatSession {
60
81
  cancelledServerIds: Set<string>;
61
82
  pendingAgentRequests: Record<string, Promise<any>>;
62
83
  aiChatHistoryCache: Record<string, { messages: ChatMessage[]; endOfList: boolean; startKeyHistory: string[] }>;
63
- 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;
64
89
  private _lidSeq: number;
65
90
 
66
91
  constructor(host: ChatHost) {
@@ -84,9 +109,91 @@ export class ChatSession {
84
109
  this.pendingAgentRequests = {};
85
110
  this.aiChatHistoryCache = {};
86
111
  this.historyItemPolls = new Map();
112
+ this._pauseReasons = new Set();
113
+ this._resuming = false;
87
114
  this._lidSeq = 0;
88
115
  }
89
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
+
90
197
  private _newLocalId(): string {
91
198
  this._lidSeq += 1;
92
199
  return 'lid_' + this._lidSeq;
@@ -101,18 +208,87 @@ export class ChatSession {
101
208
  updateHistoryCache(): void {
102
209
  var key = this.getHistoryCacheKey();
103
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.
104
219
  this.aiChatHistoryCache[key] = {
105
- messages: this.state.messages.slice(),
220
+ messages: this.state.messages.filter(function (m) {
221
+ return m._ownerKey === undefined || m._ownerKey === key;
222
+ }),
106
223
  endOfList: this.state.historyEndOfList,
107
224
  startKeyHistory: this.state.historyStartKeyHistory.slice(),
108
225
  };
109
226
  }
110
227
 
111
- private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any) {
112
- 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
+ }
113
289
  return platform === 'openai'
114
- ? callOpenAIWithPublicMcp(prompt, id.serviceId, id.owner, messages, system, model, userId, extractContent, fileUrls)
115
- : 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);
116
292
  }
117
293
 
118
294
  dispatchAgentRequest(params: any) {
@@ -126,15 +302,16 @@ export class ChatSession {
126
302
  var dispatchItemId: string | undefined;
127
303
  var sendAndPoll = function () {
128
304
  return Promise.resolve(
129
- 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)
130
306
  ).then(function (initial: any) {
131
307
  if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
132
308
  if (initial.id) {
133
309
  if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
134
310
  dispatchItemId = initial.id;
135
- self.historyItemPolls.set(initial.id, true);
136
311
  }
137
- 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;
138
315
  }
139
316
  return initial;
140
317
  });
@@ -208,24 +385,73 @@ export class ChatSession {
208
385
  // composed = clean display text; composedForLlm carries office-extraction
209
386
  // placeholders for the provider only. useBgQueue routes a post-attachment turn
210
387
  // onto the "-bg" queue so it runs after indexing.
211
- 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 {
212
389
  var self = this;
213
390
  if (!composed) return;
214
- 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();
215
398
  if (id.platform === 'none') return;
216
399
 
217
400
  var llmComposed = composedForLlm || composed;
218
401
 
219
- 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) {
220
415
  return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
221
- });
416
+ }));
222
417
 
223
418
  var aiPlatform = id.platform;
224
419
  var aiModel = id.model || undefined;
225
- var systemPrompt = this.host.buildSystemPrompt();
420
+ var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
226
421
  var userId = id.userId || id.serviceId;
227
422
  var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
228
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
+
229
455
  if (isQueuedSend) {
230
456
  var resolvedHistory = this.state.messages.filter(function (m) {
231
457
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
@@ -236,15 +462,20 @@ export class ChatSession {
236
462
  history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
237
463
  });
238
464
  var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
465
+ if (key) queuedBubble._ownerKey = key;
239
466
  if (useBgQueue) queuedBubble._useBgQueue = true;
240
467
  this.state.messages.push(queuedBubble);
241
468
  this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
242
469
 
243
- var capturedComposed = composed, capturedPlatform = aiPlatform;
244
- 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))
245
472
  .then(function (result: any) {
246
- var sendingIdx = self.state.messages.findIndex(function (m) {
247
- 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);
248
479
  });
249
480
  var serverId = result && typeof result.id === 'string' ? result.id : undefined;
250
481
  if (sendingIdx >= 0) {
@@ -255,14 +486,15 @@ export class ChatSession {
255
486
  if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
256
487
  // Track this queued item's poll so a remount/refetch dedups
257
488
  // against it instead of attaching a duplicate history poll.
258
- if (serverId) self.historyItemPolls.set(serverId, true);
259
- return result.poll({ latency: POLL_INTERVAL })
260
- .then(function (res: any) { return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId); })
261
- .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); });
262
494
  }
263
- return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId);
495
+ return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId, capturedKey);
264
496
  })
265
- .catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined); });
497
+ .catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined, capturedKey); });
266
498
  return;
267
499
  }
268
500
 
@@ -271,11 +503,10 @@ export class ChatSession {
271
503
  // view unmount), then rendered from the cache via typewriteLatestReply. A
272
504
  // later resumePendingRequest() re-renders it if the view remounted while the
273
505
  // request was still in flight.
274
- this.state.messages.push({ role: 'user', content: composed });
275
- 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 } : {}) });
276
508
  this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
277
509
 
278
- var key = this.getHistoryCacheKey();
279
510
  var historyForLlm = this.state.messages.filter(function (m) { return !m.isCancelled && !m.isBackgroundTask; });
280
511
  if (llmComposed !== composed) {
281
512
  for (var li = historyForLlm.length - 1; li >= 0; li--) {
@@ -305,8 +536,16 @@ export class ChatSession {
305
536
  // already replaced the pending bubble with the answer IN THE CACHE, so a
306
537
  // later loadChatHistory renders it.
307
538
  Promise.resolve(run).catch(function () { }).then(function () {
308
- 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.
309
547
  self.state.sending = false;
548
+ if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
310
549
  return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottom(true); });
311
550
  });
312
551
  }
@@ -320,9 +559,11 @@ export class ChatSession {
320
559
  var existing = this.state.messages[nextIdx];
321
560
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
322
561
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
562
+ if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
323
563
  this.state.messages[nextIdx] = promoted;
324
564
  var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true };
325
565
  if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
566
+ if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
326
567
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
327
568
  this.host.notify();
328
569
  }
@@ -337,6 +578,7 @@ export class ChatSession {
337
578
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
338
579
  if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
339
580
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
581
+ if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
340
582
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
341
583
  this.state.messages[nextIdx] = promoted;
342
584
  // Carry the promoted turn's _serverItemId onto the "Thinking..." placeholder
@@ -349,11 +591,16 @@ export class ChatSession {
349
591
  // unaffected.
350
592
  var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true };
351
593
  if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
594
+ if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
352
595
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
353
596
  this.host.notify();
354
597
  }
355
598
 
356
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; };
357
604
  var userIdx = -1;
358
605
  if (serverId) {
359
606
  userIdx = this.state.messages.findIndex(function (m) {
@@ -363,19 +610,19 @@ export class ChatSession {
363
610
  }
364
611
  if (userIdx === -1) {
365
612
  userIdx = this.state.messages.findIndex(function (m) {
366
- return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
613
+ return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
367
614
  });
368
615
  }
369
616
  if (userIdx === -1) {
370
617
  userIdx = this.state.messages.findIndex(function (m) {
371
- return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
618
+ return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
372
619
  });
373
620
  }
374
621
  if (serverId && this.cancelledServerIds.has(serverId)) {
375
622
  this.cancelledServerIds.delete(serverId);
376
623
  if (userIdx >= 0) {
377
624
  var ex = this.state.messages[userIdx];
378
- 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 } : {}) };
379
626
  var thIdx = this.state.messages.findIndex(function (m, i) {
380
627
  return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
381
628
  });
@@ -388,6 +635,7 @@ export class ChatSession {
388
635
  var exist = this.state.messages[userIdx];
389
636
  var repl: ChatMessage = { role: 'user', content: exist.content };
390
637
  if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
638
+ if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
391
639
  this.state.messages[userIdx] = repl;
392
640
  }
393
641
  var thinkingIdx = userIdx >= 0
@@ -402,8 +650,21 @@ export class ChatSession {
402
650
  else this.state.messages.push(msg);
403
651
  }
404
652
 
405
- onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string): void {
653
+ onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string, ownerKey?: string): void {
406
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
+ }
407
668
  var targetIdx = this.resolveQueuedUserBubble(serverId);
408
669
  if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
409
670
  if (isErrorResponseBody(response)) {
@@ -431,9 +692,19 @@ export class ChatSession {
431
692
  this.host.scrollToBottom(true);
432
693
  }
433
694
 
434
- onQueuedSendError(_composed: string, err: any, serverId?: string): void {
695
+ onQueuedSendError(_composed: string, err: any, serverId?: string, ownerKey?: string): void {
435
696
  var self = this;
436
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
+ }
437
708
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
438
709
  if (isNotExists) {
439
710
  var userIdx = serverId
@@ -736,6 +1007,8 @@ export class ChatSession {
736
1007
  var isErr = isErrorResponseBody(response);
737
1008
  var answer = isErr ? getErrorMessage(response)
738
1009
  : ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim();
1010
+ // Hide the internal indexing-completion marker from the displayed summary.
1011
+ if (!isErr && answer) answer = answer.split(INDEXING_COMPLETE_MARKER).join('').trim();
739
1012
  var idx = this.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === itemId; });
740
1013
  if (idx !== -1) {
741
1014
  // A bg "Indexing:" turn pushes a user bubble (isPendingInProcess) ALONGSIDE
@@ -793,7 +1066,11 @@ export class ChatSession {
793
1066
  }
794
1067
  this.bgTaskQueue.forEach(function (entry) {
795
1068
  if (entry.serviceId !== svcId || entry.platform !== plat) return;
796
- 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]) {
797
1074
  var isRunning = entry.status === 'running';
798
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 };
799
1076
  if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
@@ -803,11 +1080,19 @@ export class ChatSession {
803
1080
  }
804
1081
  presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
805
1082
  self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottom(false);
806
- if (!self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
807
- self.historyItemPolls.set(entry.id, true);
1083
+ }
1084
+ if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
808
1085
  var capturedId = entry.id, capturedPlat = plat;
809
- entry.poll({ latency: POLL_INTERVAL }).then(function (response: any) {
1086
+ var capturedEntry = entry;
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; }
810
1094
  self.handleHistoryItemResolution(capturedId, response, capturedPlat);
1095
+ self.maybeResumeIndexing(capturedEntry, response, capturedPlat);
811
1096
  }).catch(function (err: any) {
812
1097
  self.historyItemPolls.delete(capturedId);
813
1098
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
@@ -818,6 +1103,9 @@ export class ChatSession {
818
1103
  self.host.notify(); self.updateHistoryCache();
819
1104
  }
820
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;
821
1109
  var qi = self.bgTaskQueue.findIndex(function (q) { return q.id === capturedId; });
822
1110
  if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
823
1111
  });
@@ -826,6 +1114,61 @@ export class ChatSession {
826
1114
  this.promoteNextBgQueuedToRunning();
827
1115
  }
828
1116
 
1117
+ // Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
1118
+ // text) finished WITHOUT the completion marker, the agent ran out of room before reading
1119
+ // the whole file - dispatch a CONTINUE pass (up to a cap) that resumes from where the
1120
+ // already-saved records leave off. Additive + guarded so it never loops forever and
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.
1129
+ maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
1130
+ var self = this;
1131
+ try {
1132
+ if (!entry || !entry.storagePath) return;
1133
+ if (!isPagedReadFile(entry.filename, entry.mime)) return;
1134
+ if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop
1135
+ if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
1136
+ var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
1137
+ if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
1138
+ var pass = (entry.resumePass || 0) + 1;
1139
+ if (pass > MAX_INDEXING_RESUME_PASSES) return; // give up after the cap
1140
+ var id = this.host.getIdentity();
1141
+ if (!id || id.platform === 'none' || id.serviceId !== entry.serviceId) return;
1142
+ notifyAgentContinueIndexing({
1143
+ platform: id.platform as 'claude' | 'openai',
1144
+ model: id.model,
1145
+ service: id.serviceId,
1146
+ owner: id.owner,
1147
+ userId: id.userId || id.serviceId,
1148
+ serviceName: id.serviceName,
1149
+ serviceDescription: id.serviceDescription,
1150
+ attachment: {
1151
+ name: entry.filename,
1152
+ storagePath: entry.storagePath,
1153
+ mime: entry.mime,
1154
+ size: entry.size,
1155
+ url: '',
1156
+ },
1157
+ }).then(function (ack: any) {
1158
+ if (ack && typeof ack.id === 'string') {
1159
+ self.bgTaskQueue.push({
1160
+ serviceId: id.serviceId, platform: id.platform as 'claude' | 'openai', id: ack.id,
1161
+ filename: entry.filename, storagePath: entry.storagePath,
1162
+ isReindex: entry.isReindex, mime: entry.mime, size: entry.size,
1163
+ status: ack.status === 'running' ? 'running' : 'pending',
1164
+ poll: ack.poll, resumePass: pass,
1165
+ });
1166
+ self.drainBgTaskQueue();
1167
+ }
1168
+ }, function (e: any) { console.error('[chat-engine] resume-indexing dispatch failed', e); });
1169
+ } catch (e) { /* best-effort: resume must never break bg-task resolution */ }
1170
+ }
1171
+
829
1172
  // --- history fetch + pagination --------------------------------------
830
1173
  // Initial load (fetchMore=false) replaces the list (with in-flight rescue +
831
1174
  // cancelled-merge) and attaches polls to running/pending items; pagination
@@ -835,6 +1178,11 @@ export class ChatSession {
835
1178
  loadHistory(fetchMore?: boolean, token?: number): Promise<void> {
836
1179
  var self = this;
837
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;
838
1186
  if (token === undefined) token = this.state.gateRefreshToken;
839
1187
  if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.serviceId) {
840
1188
  return Promise.resolve();
@@ -857,9 +1205,9 @@ export class ChatSession {
857
1205
  if (token !== self.state.gateRefreshToken) return;
858
1206
  var chatList = history && Array.isArray(history.list) ? history.list : [];
859
1207
  chatList.forEach(function (item: any) {
860
- 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)) {
861
1209
  var userText = extractLastUserTextFromRequest(item.request_body);
862
- 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;
863
1211
  else item._isOnBgQueue = true;
864
1212
  }
865
1213
  });
@@ -895,6 +1243,10 @@ export class ChatSession {
895
1243
  for (var ri = 0; ri < self.state.messages.length; ri++) {
896
1244
  var mm = self.state.messages[ri];
897
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;
898
1250
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
899
1251
  if (!mm._serverItemId) {
900
1252
  if (mappedHasPendingAssistant) continue;
@@ -957,11 +1309,13 @@ export class ChatSession {
957
1309
  // dispatch never polls, so they MUST still get their poll — skipping
958
1310
  // them was the stranded-"Thinking" bug this guard must not revive.
959
1311
  if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
960
- 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;
961
1315
  var capturedId = item.id;
962
1316
  var pp = item.poll({
963
1317
  latency: POLL_INTERVAL,
964
- onResponse: function (response: any) { self.handleHistoryItemResolution(capturedId, response, platform); },
1318
+ onResponse: function (response: any) { if (isPollStopped(response)) return; self.handleHistoryItemResolution(capturedId, response, platform); },
965
1319
  onError: function (err: any) {
966
1320
  self.historyItemPolls.delete(capturedId);
967
1321
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
@@ -984,6 +1338,12 @@ export class ChatSession {
984
1338
  }
985
1339
  },
986
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);
987
1347
  if (pp && pp.catch) pp.catch(function () { });
988
1348
  });
989
1349
  self.drainBgTaskQueue();