bunnyquery 1.6.0 → 1.7.0

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,25 @@ 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, isWindowedReadFile } from './office';
38
+ import { windowedIndexingEnabled } from './config';
38
39
  import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
39
40
  import { buildBoundedChatMessages } from './budget';
40
41
  import { createInlineLinkRegex } from './links';
41
42
  import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
42
43
  import { parseAttachmentContent } from './attachment_parsers';
43
- import type { ChatHost, ChatState, ChatMessage } from './host';
44
+ import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
45
+ import type { IndexingGroup } from './indexing_groups';
44
46
 
45
47
  function sleep(ms: number): Promise<void> {
46
48
  return new Promise(function (r) { setTimeout(r, ms); });
@@ -58,14 +60,41 @@ function nextFrame(cb: (t: number) => void): void {
58
60
  setTimeout(function () { cb(nowMs()); }, 16);
59
61
  }
60
62
 
63
+ /** A live poll registered in ChatSession.historyItemPolls. */
64
+ export type PollHandle = {
65
+ /** 'bg' = background indexing, pausable. 'fg' = a reply the user is waiting on. */
66
+ kind: 'fg' | 'bg';
67
+ /** Absent on an older skapi-js that cannot stop an attached poll. */
68
+ stop?: () => void;
69
+ };
70
+
71
+ /**
72
+ * True when a poll result came from stopPolling rather than the server. Duck-typed on
73
+ * purpose — see the note above about not importing skapi-js here.
74
+ */
75
+ function isPollStopped(res: any): boolean {
76
+ return !!res && typeof res === 'object' && res.status === 'stopped';
77
+ }
78
+
61
79
  export class ChatSession {
62
80
  host: ChatHost;
63
81
  state: ChatState;
64
82
  bgTaskQueue: BgTaskEntry[];
65
83
  cancelledServerIds: Set<string>;
84
+ /** Files whose indexing the user stopped, keyed exactly as buildChatDisplayList
85
+ * keys a group (storage path, else filename). Cancelling one pass is not
86
+ * enough on its own: the client dispatches CONTINUE passes for paged files, so
87
+ * without this the next pass is enqueued the moment the cancelled one settles.
88
+ * Cleared when a FIRST pass for the same file is queued again (a re-upload or a
89
+ * Reindex from the file manager), so stopping a file never poisons it. */
90
+ cancelledIndexKeys: Set<string>;
66
91
  pendingAgentRequests: Record<string, Promise<any>>;
67
92
  aiChatHistoryCache: Record<string, { messages: ChatMessage[]; endOfList: boolean; startKeyHistory: string[] }>;
68
- historyItemPolls: Map<string, boolean>;
93
+ historyItemPolls: Map<string, PollHandle>;
94
+ /** Non-empty while polling is paused; keyed by reason so overlapping causes
95
+ * (view detached AND tab hidden) do not resume each other prematurely. */
96
+ private _pauseReasons: Set<string>;
97
+ private _resuming: boolean;
69
98
  private _lidSeq: number;
70
99
 
71
100
  constructor(host: ChatHost) {
@@ -86,12 +115,110 @@ export class ChatSession {
86
115
  };
87
116
  this.bgTaskQueue = [];
88
117
  this.cancelledServerIds = new Set();
118
+ this.cancelledIndexKeys = new Set();
89
119
  this.pendingAgentRequests = {};
90
120
  this.aiChatHistoryCache = {};
91
121
  this.historyItemPolls = new Map();
122
+ this._pauseReasons = new Set();
123
+ this._resuming = false;
92
124
  this._lidSeq = 0;
93
125
  }
94
126
 
127
+ /**
128
+ * Register a live poll so (a) a remount dedupes against it instead of stacking a
129
+ * SECOND poll on the same item, and (b) pausePolling can stop it.
130
+ *
131
+ * `stop` comes from the SDK and may be absent on an older skapi-js, in which case the
132
+ * poll simply cannot be stopped and is left running — see pausePolling.
133
+ */
134
+ private _trackPoll(id: string, kind: 'fg' | 'bg', p: any): any {
135
+ var stop = p && typeof p.stop === 'function' ? p.stop.bind(p) : undefined;
136
+ if (!stop) {
137
+ // The SDK could not give us a handle. Almost always means an older skapi-js
138
+ // is loaded (or a stale bundler dep cache) — the poll will be unstoppable.
139
+ console.debug('[chat-engine] poll has no stop handle', { id: id, kind: kind });
140
+ }
141
+ this.historyItemPolls.set(id, { kind: kind, stop: stop });
142
+ return p;
143
+ }
144
+
145
+ /**
146
+ * Stop and forget one item's poll. Used after a cancel: the row is either gone
147
+ * (cancelled while queued) or flagged cancelled (cancelled while running), so
148
+ * asking about it again only burns requests. Safe when no poll is attached, and
149
+ * safe on an older skapi-js with no stop handle (the entry is then LEFT in the
150
+ * map so a later drain cannot stack a second, unstoppable poll on the id).
151
+ */
152
+ private _stopPoll(id: string): void {
153
+ var handle = this.historyItemPolls.get(id);
154
+ if (!handle) return;
155
+ if (typeof handle.stop !== 'function') return;
156
+ try { handle.stop(); } catch (e) { /* best effort */ }
157
+ this.historyItemPolls.delete(id);
158
+ }
159
+
160
+ /** True while any pause reason is active. */
161
+ isPollingPaused(): boolean {
162
+ return this._pauseReasons.size > 0;
163
+ }
164
+
165
+ /**
166
+ * Stop BACKGROUND polling until resumePolling. Foreground polls (a reply the user is
167
+ * waiting on) keep running deliberately: their results must still land in the history
168
+ * cache so resumePendingRequest can render them on return, otherwise a user who sends
169
+ * a message then navigates away comes back to a permanently stuck "Thinking...".
170
+ *
171
+ * Server-side work is untouched; this only stops asking about it. That is safe for
172
+ * document indexing because the worker drives that loop itself.
173
+ */
174
+ pausePolling(reason: string): void {
175
+ this._pauseReasons.add(reason || 'paused');
176
+ var self = this;
177
+ var stopped: string[] = [];
178
+ this.historyItemPolls.forEach(function (handle, id) {
179
+ if (!handle || handle.kind !== 'bg') return;
180
+ // No stop available (older SDK): LEAVE the entry in place. Deleting it would
181
+ // let a later drain attach a second, uncancellable poll on the same item.
182
+ if (typeof handle.stop !== 'function') return;
183
+ try { handle.stop(); } catch (e) { /* best effort */ }
184
+ stopped.push(id);
185
+ });
186
+ // Delete only what we actually stopped, one at a time. NEVER clear() the map:
187
+ // wholesale clearing is what previously let loadHistory attach a duplicate poll
188
+ // on a live item, producing duplicate replies and stranded "Thinking" bubbles.
189
+ stopped.forEach(function (id) { self.historyItemPolls.delete(id); });
190
+
191
+ }
192
+
193
+ /**
194
+ * Lift a pause reason WITHOUT running the reconcile. For a caller that is about to
195
+ * reload history anyway (a view remounting), letting resumePolling also reconcile
196
+ * would race that load and can double-attach.
197
+ */
198
+ clearPauseReason(reason: string): void {
199
+ this._pauseReasons.delete(reason || 'paused');
200
+ }
201
+
202
+ /**
203
+ * Clear a pause reason and, once none remain, re-attach polling and reconcile.
204
+ * Deliberately does NOT touch gateRefreshToken: bumping it would silently discard
205
+ * the results of anything still in flight across the pause.
206
+ */
207
+ resumePolling(reason: string): Promise<void> {
208
+ this._pauseReasons.delete(reason || 'paused');
209
+ if (this._pauseReasons.size > 0 || this._resuming) return Promise.resolve();
210
+ if (!this.host.isViewMounted || !this.host.isViewMounted()) return Promise.resolve();
211
+ var self = this;
212
+ this._resuming = true;
213
+ return Promise.resolve()
214
+ .then(function () {
215
+ self.drainBgTaskQueue();
216
+ return self.loadHistory(false, self.state.gateRefreshToken);
217
+ })
218
+ .catch(function (e: any) { console.error('[chat-engine] resume polling failed', e); })
219
+ .then(function () { self._resuming = false; });
220
+ }
221
+
95
222
  private _newLocalId(): string {
96
223
  this._lidSeq += 1;
97
224
  return 'lid_' + this._lidSeq;
@@ -106,18 +233,101 @@ export class ChatSession {
106
233
  updateHistoryCache(): void {
107
234
  var key = this.getHistoryCacheKey();
108
235
  if (!key) return;
236
+ // Never persist another chat's in-flight bubbles under THIS key. The
237
+ // dashboard shares one ChatSession across projects, so a turn dispatched
238
+ // in project A can still be sitting in state.messages while the identity
239
+ // (and therefore `key`) already points at project B — without this filter
240
+ // A's user + "Thinking..." bubbles get written into B's cache entry and
241
+ // replay on every later visit to B. Bubbles with no _ownerKey (server
242
+ // history, bg tasks) are always kept. Single pass: this runs on the
243
+ // typewriter hot path.
109
244
  this.aiChatHistoryCache[key] = {
110
- messages: this.state.messages.slice(),
245
+ messages: this.state.messages.filter(function (m) {
246
+ return m._ownerKey === undefined || m._ownerKey === key;
247
+ }),
111
248
  endOfList: this.state.historyEndOfList,
112
249
  startKeyHistory: this.state.historyStartKeyHistory.slice(),
113
250
  };
114
251
  }
115
252
 
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();
253
+ /**
254
+ * Land a resolved reply in the history cache of a chat that is NOT currently
255
+ * visible, without touching state.messages. Mirrors the cache-only path in
256
+ * dispatchAgentRequest: REPLACE the trailing pending "Thinking..." bubble
257
+ * (append only when there is none), and settle the matching pending user
258
+ * bubble, so the cached copy never keeps a stuck "Thinking..." that a later
259
+ * cache-first load would re-render forever.
260
+ */
261
+ private _applyReplyToCache(key: string, reply: ChatMessage, serverId?: string): void {
262
+ if (!key) return;
263
+ var existing = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
264
+ var msgs = existing.messages.slice();
265
+
266
+ var thIdx = -1;
267
+ for (var i = msgs.length - 1; i >= 0; i--) {
268
+ var m = msgs[i];
269
+ if (!m || !m.isPending || m.role !== 'assistant' || m.isBackgroundTask) continue;
270
+ if (serverId && m._serverItemId && m._serverItemId !== serverId) continue;
271
+ thIdx = i; break;
272
+ }
273
+ if (thIdx !== -1) {
274
+ if (reply._serverItemId === undefined && msgs[thIdx]._serverItemId !== undefined) reply._serverItemId = msgs[thIdx]._serverItemId;
275
+ msgs[thIdx] = reply;
276
+ } else {
277
+ // No pending placeholder left to replace. Before appending, check whether
278
+ // this turn is ALREADY in the cache — a history fetch may have mapped the
279
+ // resolved turn in while the reply was in flight. Appending blindly is how
280
+ // one turn ended up rendered twice (the duplicated question+answer): the
281
+ // cache is restored verbatim on the next mount, so a duplicate written
282
+ // here survives every later visit.
283
+ var dupIdx = -1;
284
+ if (serverId) {
285
+ for (var d = msgs.length - 1; d >= 0; d--) {
286
+ var dm = msgs[d];
287
+ if (dm && dm.role === 'assistant' && dm._serverItemId === serverId) { dupIdx = d; break; }
288
+ }
289
+ }
290
+ if (dupIdx !== -1) msgs[dupIdx] = reply;
291
+ else msgs.push(reply);
292
+ }
293
+
294
+ // Settle the user bubble this turn belongs to (first pending one, or the
295
+ // one carrying serverId) so it stops rendering as still in flight.
296
+ for (var j = 0; j < msgs.length; j++) {
297
+ var u = msgs[j];
298
+ if (!u || u.role !== 'user' || u.isBackgroundTask) continue;
299
+ if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
300
+ if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
301
+ var settled: ChatMessage = { role: 'user', content: u.content };
302
+ if (u._serverItemId !== undefined) settled._serverItemId = u._serverItemId;
303
+ if (u._ownerKey !== undefined) settled._ownerKey = u._ownerKey;
304
+ msgs[j] = settled;
305
+ break;
306
+ }
307
+
308
+ this.aiChatHistoryCache[key] = {
309
+ messages: msgs,
310
+ endOfList: existing.endOfList,
311
+ startKeyHistory: existing.startKeyHistory,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * serviceId/owner are passed explicitly by every caller: a request can be
317
+ * dispatched after the user moved to another project, and re-reading the live
318
+ * identity here would silently send the turn to THAT project instead of the
319
+ * one it was composed for. Falls back to the live read only when a caller
320
+ * omits them.
321
+ */
322
+ private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any, serviceId?: string, owner?: string) {
323
+ if (serviceId === undefined || owner === undefined) {
324
+ var id = this.host.getIdentity();
325
+ if (serviceId === undefined) serviceId = id.serviceId;
326
+ if (owner === undefined) owner = id.owner;
327
+ }
118
328
  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);
329
+ ? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls)
330
+ : callClaudeWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls);
121
331
  }
122
332
 
123
333
  dispatchAgentRequest(params: any) {
@@ -131,15 +341,16 @@ export class ChatSession {
131
341
  var dispatchItemId: string | undefined;
132
342
  var sendAndPoll = function () {
133
343
  return Promise.resolve(
134
- self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls)
344
+ self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.serviceId, params.owner)
135
345
  ).then(function (initial: any) {
136
346
  if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
137
347
  if (initial.id) {
138
348
  if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
139
349
  dispatchItemId = initial.id;
140
- self.historyItemPolls.set(initial.id, true);
141
350
  }
142
- return initial.poll({ latency: POLL_INTERVAL });
351
+ var dp = initial.poll({ latency: POLL_INTERVAL });
352
+ if (initial.id) self._trackPoll(initial.id, 'fg', dp);
353
+ return dp;
143
354
  }
144
355
  return initial;
145
356
  });
@@ -213,24 +424,73 @@ export class ChatSession {
213
424
  // composed = clean display text; composedForLlm carries office-extraction
214
425
  // placeholders for the provider only. useBgQueue routes a post-attachment turn
215
426
  // onto the "-bg" queue so it runs after indexing.
216
- dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any): void {
427
+ dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any, pinned?: PinnedDispatchContext): void {
217
428
  var self = this;
218
429
  if (!composed) return;
219
- var id = this.host.getIdentity();
430
+ // A send can be dispatched LONG after the user hit Send (attachment
431
+ // uploads are awaited first), by which time the live identity may have
432
+ // moved to another project. The caller pins the identity + system prompt
433
+ // it captured at Send time so the request still goes to the project the
434
+ // question was actually asked of. Falls back to the live read when the
435
+ // caller doesn't pin (the widget, which has only one project anyway).
436
+ var id = pinned ? pinned.identity : this.host.getIdentity();
220
437
  if (id.platform === 'none') return;
221
438
 
222
439
  var llmComposed = composedForLlm || composed;
223
440
 
224
- var isQueuedSend = useBgQueue || this.state.sending || this.state.messages.some(function (m) {
441
+ // Cache key of the chat this turn belongs to. Every locally-created
442
+ // bubble is stamped with it so a project switch (which flips
443
+ // getIdentity()/getHistoryCacheKey() to the new project) can't
444
+ // misattribute this turn's bubbles to that project.
445
+ // (platform === 'none' already returned above, so serviceId is the only gate)
446
+ var key = !id.serviceId ? '' : id.serviceId + '#' + id.platform;
447
+ // True when the pinned chat is NOT the one currently on screen. Then
448
+ // state.messages belongs to a different project and MUST NOT be touched:
449
+ // the turn is staged in the pinned chat's cache instead and shows up when
450
+ // the user navigates back to it.
451
+ var offChat = !!key && key !== this.getHistoryCacheKey();
452
+
453
+ var isQueuedSend = !offChat && (useBgQueue || this.state.sending || this.state.messages.some(function (m) {
225
454
  return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
226
- });
455
+ }));
227
456
 
228
457
  var aiPlatform = id.platform;
229
458
  var aiModel = id.model || undefined;
230
- var systemPrompt = this.host.buildSystemPrompt();
459
+ var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
231
460
  var userId = id.userId || id.serviceId;
232
461
  var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
233
462
 
463
+ if (offChat) {
464
+ // Stage the turn in the pinned chat's cache and dispatch. The
465
+ // client-side queue can't be consulted (its state is the other
466
+ // project's), but the SERVER serializes per queue name, so ordering
467
+ // within the pinned chat still holds. dispatchAgentRequest replaces
468
+ // the pending bubble in this same cache entry when the reply lands.
469
+ var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function (m) {
470
+ return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
471
+ !m.isCancelled && !m.isBackgroundTask;
472
+ });
473
+ var offBounded = buildBoundedChatMessages({
474
+ platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
475
+ history: offHistory.concat([{ role: 'user', content: llmComposed }]),
476
+ });
477
+ var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
478
+ this.aiChatHistoryCache[key] = {
479
+ messages: offExisting.messages.concat([
480
+ { role: 'user', content: composed, _ownerKey: key },
481
+ { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
482
+ ]),
483
+ endOfList: offExisting.endOfList,
484
+ startKeyHistory: offExisting.startKeyHistory,
485
+ };
486
+ this.dispatchAgentRequest({
487
+ key: key, serviceId: id.serviceId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
488
+ systemPrompt: systemPrompt, text: composed, boundedMessages: offBounded.messages, userId: chatQueue,
489
+ extractContent: extractContent, fileUrls: fileUrls,
490
+ });
491
+ return;
492
+ }
493
+
234
494
  if (isQueuedSend) {
235
495
  var resolvedHistory = this.state.messages.filter(function (m) {
236
496
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
@@ -241,15 +501,20 @@ export class ChatSession {
241
501
  history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
242
502
  });
243
503
  var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
504
+ if (key) queuedBubble._ownerKey = key;
244
505
  if (useBgQueue) queuedBubble._useBgQueue = true;
245
506
  this.state.messages.push(queuedBubble);
246
507
  this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
247
508
 
248
- var capturedComposed = composed, capturedPlatform = aiPlatform;
249
- Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls))
509
+ var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
510
+ Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner))
250
511
  .then(function (result: any) {
251
- var sendingIdx = self.state.messages.findIndex(function (m) {
252
- return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user';
512
+ // Only ack a bubble that belongs to THIS chat — the search is
513
+ // positional, so on another project it would stamp this turn's
514
+ // _serverItemId onto that project's unrelated in-flight bubble.
515
+ var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function (m) {
516
+ return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user' &&
517
+ (m._ownerKey === undefined || m._ownerKey === capturedKey);
253
518
  });
254
519
  var serverId = result && typeof result.id === 'string' ? result.id : undefined;
255
520
  if (sendingIdx >= 0) {
@@ -260,14 +525,15 @@ export class ChatSession {
260
525
  if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
261
526
  // Track this queued item's poll so a remount/refetch dedups
262
527
  // 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); });
528
+ var qp = result.poll({ latency: POLL_INTERVAL });
529
+ if (serverId) self._trackPoll(serverId, 'fg', qp);
530
+ return qp
531
+ .then(function (res: any) { if (isPollStopped(res)) return; return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId, capturedKey); })
532
+ .catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, serverId, capturedKey); });
267
533
  }
268
- return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId);
534
+ return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId, capturedKey);
269
535
  })
270
- .catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined); });
536
+ .catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined, capturedKey); });
271
537
  return;
272
538
  }
273
539
 
@@ -276,11 +542,10 @@ export class ChatSession {
276
542
  // view unmount), then rendered from the cache via typewriteLatestReply. A
277
543
  // later resumePendingRequest() re-renders it if the view remounted while the
278
544
  // 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 });
545
+ this.state.messages.push({ role: 'user', content: composed, ...(key ? { _ownerKey: key } : {}) });
546
+ this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
281
547
  this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
282
548
 
283
- var key = this.getHistoryCacheKey();
284
549
  var historyForLlm = this.state.messages.filter(function (m) { return !m.isCancelled && !m.isBackgroundTask; });
285
550
  if (llmComposed !== composed) {
286
551
  for (var li = historyForLlm.length - 1; li >= 0; li--) {
@@ -310,9 +575,17 @@ export class ChatSession {
310
575
  // already replaced the pending bubble with the answer IN THE CACHE, so a
311
576
  // later loadChatHistory renders it.
312
577
  Promise.resolve(run).catch(function () { }).then(function () {
313
- if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
578
+ // Clear `sending` UNCONDITIONALLY. It is session-global (it gates
579
+ // isQueuedSend above, plus the platform/model pickers in the view), so
580
+ // leaving it set when the user navigated to another project wedged
581
+ // every subsequent send in EVERY project onto the queued path and kept
582
+ // the view's user-bubble rescue arm armed forever. Only the RENDER of
583
+ // the reply stays gated on still showing this chat — when it isn't,
584
+ // dispatchAgentRequest has already written the answer into the cache
585
+ // under `key`, so a later loadChatHistory renders it.
314
586
  self.state.sending = false;
315
- return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottom(true); });
587
+ if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
588
+ return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
316
589
  });
317
590
  }
318
591
 
@@ -324,10 +597,13 @@ export class ChatSession {
324
597
  if (nextIdx === -1) return;
325
598
  var existing = this.state.messages[nextIdx];
326
599
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
600
+ if (existing._indexFile) promoted._indexFile = existing._indexFile;
327
601
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
602
+ if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
328
603
  this.state.messages[nextIdx] = promoted;
329
604
  var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true };
330
605
  if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
606
+ if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
331
607
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
332
608
  this.host.notify();
333
609
  }
@@ -341,7 +617,9 @@ export class ChatSession {
341
617
  var existing = this.state.messages[nextIdx];
342
618
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
343
619
  if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
620
+ if (existing._indexFile) promoted._indexFile = existing._indexFile;
344
621
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
622
+ if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
345
623
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
346
624
  this.state.messages[nextIdx] = promoted;
347
625
  // Carry the promoted turn's _serverItemId onto the "Thinking..." placeholder
@@ -354,11 +632,16 @@ export class ChatSession {
354
632
  // unaffected.
355
633
  var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true };
356
634
  if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
635
+ if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
357
636
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
358
637
  this.host.notify();
359
638
  }
360
639
 
361
640
  resolveQueuedUserBubble(serverId?: string): number | undefined {
641
+ // The two fallbacks below match by POSITION, not identity, so they must
642
+ // never consider a bubble stamped for a different chat.
643
+ var liveKey = this.getHistoryCacheKey();
644
+ var isLocal = function (m: ChatMessage) { return m._ownerKey === undefined || m._ownerKey === liveKey; };
362
645
  var userIdx = -1;
363
646
  if (serverId) {
364
647
  userIdx = this.state.messages.findIndex(function (m) {
@@ -368,19 +651,19 @@ export class ChatSession {
368
651
  }
369
652
  if (userIdx === -1) {
370
653
  userIdx = this.state.messages.findIndex(function (m) {
371
- return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
654
+ return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
372
655
  });
373
656
  }
374
657
  if (userIdx === -1) {
375
658
  userIdx = this.state.messages.findIndex(function (m) {
376
- return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
659
+ return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
377
660
  });
378
661
  }
379
662
  if (serverId && this.cancelledServerIds.has(serverId)) {
380
663
  this.cancelledServerIds.delete(serverId);
381
664
  if (userIdx >= 0) {
382
665
  var ex = this.state.messages[userIdx];
383
- this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId };
666
+ this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...(ex._ownerKey !== undefined ? { _ownerKey: ex._ownerKey } : {}) };
384
667
  var thIdx = this.state.messages.findIndex(function (m, i) {
385
668
  return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
386
669
  });
@@ -393,6 +676,7 @@ export class ChatSession {
393
676
  var exist = this.state.messages[userIdx];
394
677
  var repl: ChatMessage = { role: 'user', content: exist.content };
395
678
  if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
679
+ if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
396
680
  this.state.messages[userIdx] = repl;
397
681
  }
398
682
  var thinkingIdx = userIdx >= 0
@@ -407,8 +691,21 @@ export class ChatSession {
407
691
  else this.state.messages.push(msg);
408
692
  }
409
693
 
410
- onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string): void {
694
+ onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string, ownerKey?: string): void {
411
695
  if (serverId) this.historyItemPolls.delete(serverId);
696
+ // This turn resolved while a DIFFERENT chat is on screen (the user moved to
697
+ // another project mid-flight). state.messages now belongs to that chat, and
698
+ // resolveQueuedUserBubble's positional fallbacks would happily hijack ITS
699
+ // pending bubble — or, finding nothing, push this answer onto its list.
700
+ // Settle in the owning chat's cache instead and leave the view untouched.
701
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
702
+ var offReply: ChatMessage = isErrorResponseBody(response)
703
+ ? { role: 'assistant', content: getErrorMessage(response), isError: true }
704
+ : { role: 'assistant', content: ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim() || 'No text response received from AI provider.' };
705
+ this._applyReplyToCache(ownerKey, offReply, serverId);
706
+ if (serverId) this.cancelledServerIds.delete(serverId);
707
+ return;
708
+ }
412
709
  var targetIdx = this.resolveQueuedUserBubble(serverId);
413
710
  if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
414
711
  if (isErrorResponseBody(response)) {
@@ -433,12 +730,24 @@ export class ChatSession {
433
730
  this.promoteNextQueuedToRunning();
434
731
  this.updateHistoryCache();
435
732
  this.host.notify();
436
- this.host.scrollToBottom(true);
733
+ // ARRIVAL, not a user action: only follow if the user is still pinned to
734
+ // the bottom. Scrolled-up readers keep their position.
735
+ this.host.scrollToBottomIfSticky(true);
437
736
  }
438
737
 
439
- onQueuedSendError(_composed: string, err: any, serverId?: string): void {
738
+ onQueuedSendError(_composed: string, err: any, serverId?: string, ownerKey?: string): void {
440
739
  var self = this;
441
740
  if (serverId) this.historyItemPolls.delete(serverId);
741
+ // Off-chat resolution — see onQueuedSendResponse. Settle in the owning
742
+ // chat's cache rather than mutating whatever chat is on screen now.
743
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
744
+ var isGone = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
745
+ this._applyReplyToCache(ownerKey, isGone
746
+ ? { role: 'assistant', content: 'Request was cancelled.', isError: true }
747
+ : { role: 'assistant', content: getErrorMessage(err), isError: true }, serverId);
748
+ if (serverId) this.cancelledServerIds.delete(serverId);
749
+ return;
750
+ }
442
751
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
443
752
  if (isNotExists) {
444
753
  var userIdx = serverId
@@ -466,14 +775,14 @@ export class ChatSession {
466
775
  }
467
776
  if (serverId) this.cancelledServerIds.delete(serverId);
468
777
  this._removeStrayPendingAssistants();
469
- this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
778
+ this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottomIfSticky(true);
470
779
  return;
471
780
  }
472
781
  var targetIdx = this.resolveQueuedUserBubble(serverId);
473
782
  if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
474
783
  this.insertAtTarget({ role: 'assistant', content: getErrorMessage(err), isError: true }, targetIdx);
475
784
  this._removeStrayPendingAssistants();
476
- this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
785
+ this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottomIfSticky(true);
477
786
  }
478
787
 
479
788
  cancelQueuedMessage(msg: ChatMessage, idx: number): void {
@@ -493,13 +802,25 @@ export class ChatSession {
493
802
  })).then(function (result: any) {
494
803
  if (result && result.removed) {
495
804
  self.cancelledServerIds.add(serverId as string);
805
+ self._stopPoll(serverId as string);
496
806
  var qi = self.bgTaskQueue.findIndex(function (e) { return e.id === serverId; });
497
807
  if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
498
808
  var removeIdx = self.state.messages.findIndex(function (m) {
499
809
  return m._serverItemId === serverId && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user';
500
810
  });
501
811
  if (removeIdx !== -1) {
502
- self.state.messages[removeIdx] = { role: 'user', content: self.state.messages[removeIdx].content, isCancelled: true, _serverItemId: serverId };
812
+ // Carry the background/file markers onto the rebuild. Dropping them
813
+ // took a cancelled INDEXING pass out of its file's collapsed row
814
+ // (buildChatDisplayList only groups isBackgroundTask bubbles), so the
815
+ // row stayed "Indexing…" forever while a bare "Indexing: file" bubble
816
+ // appeared beside it.
817
+ var wasMsg = self.state.messages[removeIdx];
818
+ var cancelledMsg: ChatMessage = { role: 'user', content: wasMsg.content, isCancelled: true, _serverItemId: serverId };
819
+ if (wasMsg.isBackgroundTask) cancelledMsg.isBackgroundTask = true;
820
+ if (wasMsg._indexFile) cancelledMsg._indexFile = wasMsg._indexFile;
821
+ if (wasMsg._useBgQueue) cancelledMsg._useBgQueue = true;
822
+ if (wasMsg._ownerKey !== undefined) cancelledMsg._ownerKey = wasMsg._ownerKey;
823
+ self.state.messages[removeIdx] = cancelledMsg;
503
824
  var thById = self.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.isPending && m.role === 'assistant'; });
504
825
  if (thById !== -1) self.state.messages.splice(thById, 1);
505
826
  else {
@@ -525,6 +846,47 @@ export class ChatSession {
525
846
  });
526
847
  }
527
848
 
849
+ /**
850
+ * Stop indexing a file, from its collapsed row — every pass at once, not just
851
+ * the bubble the user happens to see.
852
+ *
853
+ * A big file is indexed as a CHAIN of passes, so cancelling only the live one
854
+ * accomplishes nothing: the next pass is dispatched as soon as it settles.
855
+ * Three things end the chain:
856
+ * 1. every queued/running pass of this file is cancelled server-side
857
+ * (csr-cancel deletes a queued row and flags a running one "cancelled",
858
+ * which is also the worker's gate for NOT enqueueing the next window);
859
+ * 2. the file is remembered in cancelledIndexKeys, so the client-driven
860
+ * resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
861
+ * 3. any of its passes still sitting in bgTaskQueue is dropped by the next
862
+ * drain rather than surfacing a fresh "Indexing…" bubble.
863
+ *
864
+ * Records already written by the passes that DID run are kept — this stops the
865
+ * work, it does not undo it.
866
+ */
867
+ cancelIndexingGroup(group: IndexingGroup): void {
868
+ var self = this;
869
+ if (!group || !group.key) return;
870
+ // The group belongs to the chat on screen, so scope its key the same way
871
+ // _indexKeyOf scopes a queued task's.
872
+ var scoped = this.getHistoryCacheKey() + '|' + group.key;
873
+ this.cancelledIndexKeys.add(scoped);
874
+ var ids = group.cancellableIds || [];
875
+ if (!ids.length) { this.host.notify(); return; }
876
+ ids.forEach(function (serverId) {
877
+ // Address the message by IDENTITY, not by the index captured when the
878
+ // display list was built: an earlier cancel in this same loop may already
879
+ // have spliced the array (a group can have more than one live pass), and a
880
+ // stale index would mark the WRONG bubble as cancelling.
881
+ var idx = self.state.messages.findIndex(function (m) {
882
+ return m._serverItemId === serverId && m.role === 'user' &&
883
+ (m.isPendingQueued || m.isPendingInProcess);
884
+ });
885
+ if (idx === -1) return;
886
+ self.cancelQueuedMessage(self.state.messages[idx], idx);
887
+ });
888
+ }
889
+
528
890
  // --- typewriter -------------------------------------------------------
529
891
  // Reveal `fullText` into a message bubble at a constant wall-clock RATE
530
892
  // (chars/second) driven by requestAnimationFrame, rather than a fixed number
@@ -706,6 +1068,9 @@ export class ChatSession {
706
1068
  var u = this.state.messages[uIdx];
707
1069
  var cleaned: ChatMessage = { role: 'user', content: u.content, _serverItemId: itemId };
708
1070
  if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
1071
+ // Carry the file ref: it is what keeps this pass in its file's collapsed
1072
+ // row (the label parser is only a fallback for older cached bubbles).
1073
+ if (u._indexFile) cleaned._indexFile = u._indexFile;
709
1074
  this.state.messages[uIdx] = cleaned;
710
1075
  }
711
1076
 
@@ -722,11 +1087,11 @@ export class ChatSession {
722
1087
  if (!pending) return Promise.resolve();
723
1088
  if (this.state.messages.some(function (m) { return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue; })) return Promise.resolve();
724
1089
  this.state.sending = true;
725
- this.host.scrollToBottom(true);
1090
+ this.host.scrollToBottomIfSticky(true);
726
1091
  return Promise.resolve(pending).catch(function () { }).then(function () {
727
1092
  if (token !== self.state.gateRefreshToken) return;
728
1093
  self.state.sending = false;
729
- return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottom(true); });
1094
+ return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
730
1095
  });
731
1096
  }
732
1097
 
@@ -750,13 +1115,35 @@ export class ChatSession {
750
1115
  // bubble stuck pending — and drainBgTaskQueue then never clears its queue
751
1116
  // entry (its stillPending check stays true). Un-pend it here too.
752
1117
  this._clearPendingUserBubble(itemId);
1118
+ // Carry isBackgroundTask onto the REPLACEMENT. These rebuilds are fresh
1119
+ // object literals, so anything not copied is lost — and dropping this flag
1120
+ // made a pass that resolved LIVE fall out of its file's collapsed row
1121
+ // (buildChatDisplayList only considers isBackgroundTask messages), while
1122
+ // the same pass rebuilt from history stayed grouped. That mismatch is the
1123
+ // "new indexing response renders outside the group" bug.
1124
+ var wasBgTask = !!this.state.messages[idx].isBackgroundTask;
753
1125
  if (isErr) {
754
1126
  this.state.messages[idx] = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
1127
+ if (wasBgTask) this.state.messages[idx].isBackgroundTask = true;
1128
+ this.host.notify(); this.updateHistoryCache(); return;
1129
+ }
1130
+ var text = answer || 'No text response received from AI provider.';
1131
+ // A background-indexing reply is INSIDE a collapsed row: nothing of it is
1132
+ // on screen. Typewriting it anyway put it on the one shared, serial
1133
+ // typewriter queue, so the user's own next reply — which IS on screen —
1134
+ // lost its "Thinking..." spinner and sat as an EMPTY bubble for however
1135
+ // long the invisible indexing summary took to reveal (seconds per pass,
1136
+ // additive across files). It also held state.typing true for the whole
1137
+ // time, which disables the platform/model pickers and no-ops Clear
1138
+ // history, and ran a per-frame scroll for content nobody can see.
1139
+ // Write it straight in; expanding the row then shows it complete.
1140
+ if (wasBgTask) {
1141
+ this.state.messages[idx] = { role: 'assistant', content: text, isBackgroundTask: true, _serverItemId: itemId };
755
1142
  this.host.notify(); this.updateHistoryCache(); return;
756
1143
  }
757
1144
  var lid = this._newLocalId();
758
1145
  this.state.messages[idx] = { role: 'assistant', content: '', _localId: lid, _serverItemId: itemId };
759
- this.host.notify(); this.enqueueTypewrite(idx, answer || 'No text response received from AI provider.', lid);
1146
+ this.host.notify(); this.enqueueTypewrite(idx, text, lid);
760
1147
  this.updateHistoryCache(); return;
761
1148
  }
762
1149
  var userIdx = this.state.messages.findIndex(function (m) {
@@ -764,23 +1151,125 @@ export class ChatSession {
764
1151
  });
765
1152
  if (userIdx === -1) return;
766
1153
  var ex = this.state.messages[userIdx];
767
- this.state.messages[userIdx] = { role: 'user', content: ex.content, _serverItemId: itemId };
1154
+ // Same carry-over as above, plus _indexFile / _useBgQueue: this rebuild is
1155
+ // what keeps an indexing REQUEST bubble in its file's collapsed row (and a
1156
+ // bg-queued chat cancelling against the right queue) once the pass settles.
1157
+ var settledUser: ChatMessage = { role: 'user', content: ex.content, _serverItemId: itemId };
1158
+ if (ex.isBackgroundTask) settledUser.isBackgroundTask = true;
1159
+ if (ex._indexFile) settledUser._indexFile = ex._indexFile;
1160
+ if (ex._useBgQueue) settledUser._useBgQueue = true;
1161
+ this.state.messages[userIdx] = settledUser;
768
1162
  if (isErr) {
769
- this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: answer, isError: true, _serverItemId: itemId });
1163
+ var errReply: ChatMessage = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
1164
+ if (ex.isBackgroundTask) errReply.isBackgroundTask = true;
1165
+ this.state.messages.splice(userIdx + 1, 0, errReply);
1166
+ this.host.notify(); this.updateHistoryCache(); return;
1167
+ }
1168
+ var text2 = answer || 'No text response received from AI provider.';
1169
+ // Same as above: a collapsed row's reply is not on screen, so revealing it
1170
+ // character by character only blocks the queue the visible reply needs.
1171
+ if (ex.isBackgroundTask) {
1172
+ this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: text2, isBackgroundTask: true, _serverItemId: itemId });
770
1173
  this.host.notify(); this.updateHistoryCache(); return;
771
1174
  }
772
1175
  var lid2 = this._newLocalId();
773
- this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: '', _localId: lid2, _serverItemId: itemId });
774
- this.host.notify(); this.enqueueTypewrite(userIdx + 1, answer || 'No text response received from AI provider.', lid2);
1176
+ var reply: ChatMessage = { role: 'assistant', content: '', _localId: lid2, _serverItemId: itemId };
1177
+ this.state.messages.splice(userIdx + 1, 0, reply);
1178
+ this.host.notify(); this.enqueueTypewrite(userIdx + 1, text2, lid2);
775
1179
  this.updateHistoryCache();
776
1180
  }
777
1181
 
1182
+ /** How a bg task maps onto a collapsed row: the row's own key (storage path
1183
+ * when known, else the filename), scoped to the chat it belongs to. A storage
1184
+ * path is project-relative ("report.xlsx"), and ONE ChatSession serves every
1185
+ * project — unscoped, stopping a file in one project would silently suppress
1186
+ * the same filename's continuations in another. */
1187
+ private _indexKeyOf(entry: BgTaskEntry): string {
1188
+ if (!entry) return '';
1189
+ var file = entry.storagePath || entry.filename;
1190
+ if (!file) return '';
1191
+ return entry.serviceId + '#' + entry.platform + '|' + file;
1192
+ }
1193
+
1194
+ /**
1195
+ * Reconcile the bg queue with the files the user has stopped.
1196
+ *
1197
+ * A FIRST pass (no resumePass) is a fresh indexing request — a re-upload, or a
1198
+ * Reindex from the file manager — so it LIFTS the stop: the key is a storage
1199
+ * path, and without this an earlier cancel would silently kill every future
1200
+ * index of the same path. A continuation of a stopped file is dropped instead,
1201
+ * covering the pass that was dispatched in the moment before the cancel landed.
1202
+ */
1203
+ private _applyIndexCancellations(): void {
1204
+ if (!this.cancelledIndexKeys.size) return;
1205
+ for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
1206
+ var entry = this.bgTaskQueue[i];
1207
+ var key = this._indexKeyOf(entry);
1208
+ if (!key || !this.cancelledIndexKeys.has(key)) continue;
1209
+ if (!entry.resumePass) { this.cancelledIndexKeys.delete(key); continue; }
1210
+ this.bgTaskQueue.splice(i, 1);
1211
+ this._stopPoll(entry.id);
1212
+ this._cancelServerItem(entry.id);
1213
+ }
1214
+ }
1215
+
1216
+ /**
1217
+ * Cancel any live pass of a stopped file that turned up on its own.
1218
+ *
1219
+ * The client is not the only thing that continues a file: for PDFs and (when
1220
+ * windowed indexing is on) text/grid files the WORKER enqueues the next window
1221
+ * itself, and that pass reaches the chat through the history poll, never
1222
+ * through bgTaskQueue. The worker's own gate stops the chain when the running
1223
+ * row is cancelled — but if the row had already finished when the user hit
1224
+ * stop, the next window was queued a moment earlier and still arrives. Stop it
1225
+ * here rather than making the user hit stop again.
1226
+ *
1227
+ * Runs from drainBgTaskQueue, which both clients call after a history load.
1228
+ */
1229
+ private _sweepCancelledIndexing(): void {
1230
+ if (!this.cancelledIndexKeys.size) return;
1231
+ var self = this;
1232
+ var chatKey = this.getHistoryCacheKey();
1233
+ var targets: { msg: ChatMessage; idx: number }[] = [];
1234
+ this.state.messages.forEach(function (m, i) {
1235
+ if (!m.isBackgroundTask || m.role !== 'user' || !m._serverItemId) return;
1236
+ if (m._cancelling || m.isSendingToServer) return;
1237
+ if (!(m.isPendingQueued || m.isPendingInProcess)) return;
1238
+ var ref = m._indexFile;
1239
+ var file = ref && (ref.path || ref.name);
1240
+ if (!file || !self.cancelledIndexKeys.has(chatKey + '|' + file)) return;
1241
+ targets.push({ msg: m, idx: i });
1242
+ });
1243
+ targets.forEach(function (t) {
1244
+ var idx = self.state.messages.indexOf(t.msg);
1245
+ self.cancelQueuedMessage(t.msg, idx === -1 ? t.idx : idx);
1246
+ });
1247
+ }
1248
+
1249
+ /** Best-effort server-side cancel of a bg-queue item that has no bubble (so
1250
+ * cancelQueuedMessage, which drives one, has nothing to act on). */
1251
+ private _cancelServerItem(serverId: string): void {
1252
+ var id = this.host.getIdentity();
1253
+ if (!serverId || (id.platform !== 'claude' && id.platform !== 'openai')) return;
1254
+ var url = id.platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
1255
+ Promise.resolve(this.host.cancelRequest({
1256
+ url: url, method: 'POST', id: serverId,
1257
+ queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
1258
+ service: id.serviceId, owner: id.owner,
1259
+ })).catch(function () { /* the pass may already have finished; nothing to do */ });
1260
+ }
1261
+
778
1262
  // Inject "Indexing: <file>" bubbles for queued bg tasks + attach their polls.
779
1263
  drainBgTaskQueue(): void {
780
1264
  var self = this;
781
1265
  var id = this.host.getIdentity();
782
1266
  var svcId = id.serviceId, plat = id.platform;
783
1267
  if (!svcId || plat === 'none' || !this.host.isViewMounted()) return;
1268
+ // Before anything is surfaced: drop continuations of files the user stopped
1269
+ // (and let a fresh first pass lift the stop), then cancel any worker-queued
1270
+ // pass of a stopped file that reached the chat through history.
1271
+ this._applyIndexCancellations();
1272
+ this._sweepCancelledIndexing();
784
1273
  // Index messages by _serverItemId ONCE, so the per-entry presence/pending
785
1274
  // checks below are O(1) instead of a full messages.some() scan each. With a
786
1275
  // large bg queue (bulk uploads) the old nested scan was O(queue x messages)
@@ -800,33 +1289,77 @@ export class ChatSession {
800
1289
  }
801
1290
  this.bgTaskQueue.forEach(function (entry) {
802
1291
  if (entry.serviceId !== svcId || entry.platform !== plat) return;
803
- if (presentIds[entry.id]) return;
1292
+ // Bubble injection and poll attachment are INDEPENDENT. An entry whose bubble
1293
+ // already exists may still need a poll — that is exactly the state a paused
1294
+ // drain leaves behind, and returning early here stranded it as a permanent
1295
+ // "Thinking..." once polling resumed.
1296
+ if (!presentIds[entry.id]) {
804
1297
  var isRunning = entry.status === 'running';
805
- var userBubble: ChatMessage = { role: 'user', content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex), isBackgroundTask: true, _serverItemId: entry.id };
1298
+ var userBubble: ChatMessage = {
1299
+ role: 'user',
1300
+ content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex, !!entry.resumePass),
1301
+ isBackgroundTask: true,
1302
+ _serverItemId: entry.id,
1303
+ // Structured ref so this live pass groups with the same file's passes
1304
+ // rebuilt from history (see indexing_groups.buildChatDisplayList).
1305
+ _indexFile: {
1306
+ name: entry.filename,
1307
+ path: entry.storagePath,
1308
+ mime: entry.mime,
1309
+ size: entry.size,
1310
+ isReindex: !!entry.isReindex,
1311
+ continued: !!entry.resumePass,
1312
+ },
1313
+ };
806
1314
  if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
807
1315
  self.state.messages.push(userBubble);
808
1316
  if (isRunning) {
809
1317
  self.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
810
1318
  }
811
1319
  presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
812
- 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);
1320
+ self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottomIfSticky(false);
1321
+ }
1322
+ if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
815
1323
  var capturedId = entry.id, capturedPlat = plat;
816
1324
  var capturedEntry = entry;
817
- entry.poll({ latency: POLL_INTERVAL }).then(function (response: any) {
1325
+ var wasStopped = false;
1326
+ var bp = entry.poll({ latency: POLL_INTERVAL });
1327
+ self._trackPoll(entry.id, 'bg', bp);
1328
+ bp.then(function (response: any) {
1329
+ // A stopped poll is not a result: leave the bubble and the queue entry
1330
+ // exactly as they were so resumePolling can re-attach.
1331
+ if (isPollStopped(response)) { wasStopped = true; return; }
818
1332
  self.handleHistoryItemResolution(capturedId, response, capturedPlat);
819
1333
  self.maybeResumeIndexing(capturedEntry, response, capturedPlat);
820
1334
  }).catch(function (err: any) {
821
1335
  self.historyItemPolls.delete(capturedId);
822
1336
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
1337
+ // Settle the REQUEST bubble first, unconditionally. Only a RUNNING
1338
+ // pass gets an assistant placeholder, so for a queued one the lookup
1339
+ // below finds nothing and every branch was skipped — leaving the
1340
+ // request bubble isPendingQueued with its queue entry about to be
1341
+ // spliced away, i.e. no poll left to ever clear it. One such bubble
1342
+ // pins the whole collapsed row to "active": a spinner and a Stop
1343
+ // button against a dead id, until the page is reloaded.
1344
+ self._clearPendingUserBubble(capturedId);
823
1345
  var bi = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
824
1346
  if (bi !== -1) {
825
1347
  if (isNotExists) self.state.messages.splice(bi, 1);
826
1348
  else self.state.messages[bi] = { role: 'assistant', content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId };
827
- self.host.notify(); self.updateHistoryCache();
1349
+ } else if (!isNotExists) {
1350
+ // No placeholder to turn into the error (a queued pass). Put one
1351
+ // after the request bubble so the row reports the failure instead
1352
+ // of quietly settling to "Indexed".
1353
+ var ui = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId; });
1354
+ if (ui !== -1) {
1355
+ self.state.messages.splice(ui + 1, 0, { role: 'assistant', content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId });
1356
+ }
828
1357
  }
1358
+ self.host.notify(); self.updateHistoryCache();
829
1359
  }).then(function () {
1360
+ // Keep the queue entry when the poll was merely stopped, or resuming
1361
+ // would have nothing left to re-attach to.
1362
+ if (wasStopped) return;
830
1363
  var qi = self.bgTaskQueue.findIndex(function (q) { return q.id === capturedId; });
831
1364
  if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
832
1365
  });
@@ -836,30 +1369,40 @@ export class ChatSession {
836
1369
  }
837
1370
 
838
1371
  // 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
1372
+ // text) finished WITHOUT the completion marker, the agent ran out of room before reading
840
1373
  // the whole file - dispatch a CONTINUE pass (up to a cap) that resumes from where the
841
1374
  // already-saved records leave off. Additive + guarded so it never loops forever and
842
1375
  // never breaks the resolution path.
1376
+ //
1377
+ // VISION files (PDFs rendered to page images) are NOT resumed here: the proxy worker
1378
+ // advances their page window itself, off the renderer's true page count. Driving them
1379
+ // from the browser is what used to lose pages on long documents - the chain lived in tab
1380
+ // memory (a reload or a closed tab ended it), and it stopped whenever the model claimed
1381
+ // completion, which on an 88-page file happened at page 15. Continuing to dispatch here
1382
+ // as well would now double-index every window.
843
1383
  maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
844
1384
  var self = this;
845
1385
  try {
846
1386
  if (!entry || !entry.storagePath) return;
1387
+ // The user stopped this file from its collapsed row. Dispatching the next
1388
+ // pass here is exactly what "stop" has to prevent — the cancelled pass
1389
+ // settles, and without this the chain simply carries on.
1390
+ if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
847
1391
  if (!isPagedReadFile(entry.filename, entry.mime)) return;
1392
+ if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop (PDF vision)
1393
+ // When windowed indexing is on, the WORKER drives the text/grid loop too. The
1394
+ // client MUST NOT also resume, or two drivers each enqueue a continuation per
1395
+ // pass and the chain FORKS - duplicate records, runaway passes. Same reason
1396
+ // PDFs early-return above. Gated on the flag so the old client-driven path is
1397
+ // untouched when windowing is off.
1398
+ if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
848
1399
  if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
849
1400
  var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
850
1401
  if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
851
1402
  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
1403
+ if (pass > MAX_INDEXING_RESUME_PASSES) return; // give up after the cap
857
1404
  var id = this.host.getIdentity();
858
1405
  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
1406
  notifyAgentContinueIndexing({
864
1407
  platform: id.platform as 'claude' | 'openai',
865
1408
  model: id.model,
@@ -868,7 +1411,6 @@ export class ChatSession {
868
1411
  userId: id.userId || id.serviceId,
869
1412
  serviceName: id.serviceName,
870
1413
  serviceDescription: id.serviceDescription,
871
- renderFrom: renderFrom,
872
1414
  attachment: {
873
1415
  name: entry.filename,
874
1416
  storagePath: entry.storagePath,
@@ -900,6 +1442,11 @@ export class ChatSession {
900
1442
  loadHistory(fetchMore?: boolean, token?: number): Promise<void> {
901
1443
  var self = this;
902
1444
  var id = this.host.getIdentity();
1445
+ // Key this fetch is FOR, snapshotted from the same identity read the
1446
+ // request is built from. The rescue below compares against this rather
1447
+ // than a live getHistoryCacheKey(), so a project switch mid-fetch can't
1448
+ // make another chat's in-flight bubbles look local.
1449
+ var loadKey = (!id.serviceId || id.platform === 'none') ? '' : id.serviceId + '#' + id.platform;
903
1450
  if (token === undefined) token = this.state.gateRefreshToken;
904
1451
  if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.serviceId) {
905
1452
  return Promise.resolve();
@@ -922,9 +1469,9 @@ export class ChatSession {
922
1469
  if (token !== self.state.gateRefreshToken) return;
923
1470
  var chatList = history && Array.isArray(history.list) ? history.list : [];
924
1471
  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) {
1472
+ if (isBgIndexingQueue(item.queue_name)) {
926
1473
  var userText = extractLastUserTextFromRequest(item.request_body);
927
- if (typeof userText === 'string' && userText.indexOf('A new file has just been uploaded') === 0) item._isBgTask = true;
1474
+ if (typeof userText === 'string' && (userText.indexOf('A new file has just been uploaded') === 0 || userText.indexOf('CONTINUE indexing') === 0)) item._isBgTask = true;
928
1475
  else item._isOnBgQueue = true;
929
1476
  }
930
1477
  });
@@ -938,6 +1485,10 @@ export class ChatSession {
938
1485
  formatIndexingLabel: self.host.formatIndexingLabel,
939
1486
  }).messages;
940
1487
 
1488
+ // Set when a first-page refresh re-prepended already-loaded older pages,
1489
+ // so the cursor reset below knows not to rewind to page 1's boundary.
1490
+ var keptOlderPages = false;
1491
+
941
1492
  if (fetchMore) {
942
1493
  self.state.messages = mapped.concat(self.state.messages);
943
1494
  } else {
@@ -945,7 +1496,17 @@ export class ChatSession {
945
1496
  var serverIds: any = {};
946
1497
  mapped.forEach(function (m: any) { if (m._serverItemId) serverIds[m._serverItemId] = 1; });
947
1498
  var locallyCancelled: any = {};
948
- self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = 1; });
1499
+ self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = m; });
1500
+ // A cancel the server has not acknowledged yet. Without carrying these
1501
+ // across the replace, a collapsed row mid-cancel drops its "Stopping..."
1502
+ // state and re-offers Stop; the second click cancels an id that is
1503
+ // already gone and writes a bogus "Could not remove from queue." onto a
1504
+ // run that WAS stopped successfully.
1505
+ var inFlightCancel: any = {};
1506
+ self.state.messages.forEach(function (m) {
1507
+ if (!m._serverItemId) return;
1508
+ if (m._cancelling || m._cancelError) inFlightCancel[m._serverItemId] = m;
1509
+ });
949
1510
  // If the freshly-mapped server list ALREADY shows this in-flight turn
950
1511
  // as a pending placeholder (a non-bg pending assistant, which carries
951
1512
  // a real _serverItemId), re-pushing the local no-_serverItemId
@@ -960,6 +1521,10 @@ export class ChatSession {
960
1521
  for (var ri = 0; ri < self.state.messages.length; ri++) {
961
1522
  var mm = self.state.messages[ri];
962
1523
  if (mm.isBackgroundTask) continue;
1524
+ // Belongs to a different chat (another project, or another
1525
+ // platform on this one) — it must not be carried onto THIS
1526
+ // chat's freshly-fetched history.
1527
+ if (mm._ownerKey !== undefined && mm._ownerKey !== loadKey) continue;
963
1528
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
964
1529
  if (!mm._serverItemId) {
965
1530
  if (mappedHasPendingAssistant) continue;
@@ -970,25 +1535,83 @@ export class ChatSession {
970
1535
  }
971
1536
  }
972
1537
  }
973
- self.state.messages = mapped;
1538
+ // PRESERVE already-loaded older pages. `mapped` is only page 1, so a
1539
+ // blind replace threw away every page the user had scrolled in. This
1540
+ // path runs from resumePolling (visibilitychange), so merely leaving
1541
+ // the tab and coming back wiped the whole scrolled-in history.
1542
+ //
1543
+ // The list is oldest-first (fetchMore PREPENDS), and ids sort with
1544
+ // newest largest, so anything already in the list whose _serverItemId
1545
+ // sorts BELOW page 1's oldest id is an older page. Those are disjoint
1546
+ // from `mapped` (strict <), so re-prepending cannot duplicate.
1547
+ var oldestInPage1: string | undefined = undefined;
1548
+ mapped.forEach(function (m: any) {
1549
+ var sid = m._serverItemId;
1550
+ if (typeof sid !== 'string') return;
1551
+ if (oldestInPage1 === undefined || sid < (oldestInPage1 as string)) oldestInPage1 = sid;
1552
+ });
1553
+ // Only merge when the current list is genuinely a continuation of THIS
1554
+ // page 1 (shares at least one id with it). A project switch, a cleared
1555
+ // horizon, or a wholly-new page means the head is unrelated history and
1556
+ // must be replaced, not spliced.
1557
+ var sharesPage1 = self.state.messages.some(function (m) {
1558
+ return typeof m._serverItemId === 'string' && !!serverIds[m._serverItemId as string];
1559
+ });
1560
+ var retainedOlder: ChatMessage[] = (!sharesPage1 || oldestInPage1 === undefined) ? [] : self.state.messages.filter(function (m) {
1561
+ // Settled server history only; in-flight bubbles are the rescue
1562
+ // loop's job below and would otherwise be kept twice.
1563
+ if (typeof m._serverItemId !== 'string') return false;
1564
+ // Same ownership predicate as the rescue loop: never carry another
1565
+ // project's bubbles onto this chat (the cross-project leak fix).
1566
+ // Compare against the snapshotted loadKey, never a live read.
1567
+ if (m._ownerKey !== undefined && m._ownerKey !== loadKey) return false;
1568
+ return (m._serverItemId as string) < (oldestInPage1 as string);
1569
+ });
1570
+ keptOlderPages = retainedOlder.length > 0;
1571
+ self.state.messages = keptOlderPages ? retainedOlder.concat(mapped) : mapped;
974
1572
  rescued.forEach(function (m) { self.state.messages.push(m); });
975
1573
  if (Object.keys(locallyCancelled).length) {
976
1574
  for (var ci = 0; ci < self.state.messages.length; ci++) {
977
1575
  var c = self.state.messages[ci];
978
1576
  if (!c._serverItemId || !locallyCancelled[c._serverItemId] || c.isCancelled) continue;
979
- self.state.messages[ci] = { role: 'user', content: c.content, isCancelled: true, _serverItemId: c._serverItemId };
1577
+ // Rebuild FROM the mapped bubble rather than as a bare literal:
1578
+ // dropping isBackgroundTask / _indexFile here ejected a stopped
1579
+ // indexing pass from its file's collapsed row, where it then rendered
1580
+ // as a standalone "Indexing: <file>" chat bubble and left the row
1581
+ // recomputing its passes as if that one had never happened.
1582
+ self.state.messages[ci] = {
1583
+ role: 'user', content: c.content, isCancelled: true, _serverItemId: c._serverItemId,
1584
+ isBackgroundTask: c.isBackgroundTask, _indexFile: c._indexFile,
1585
+ _useBgQueue: c._useBgQueue, _ownerKey: c._ownerKey,
1586
+ };
980
1587
  if (ci + 1 < self.state.messages.length && self.state.messages[ci + 1].isPending && self.state.messages[ci + 1]._serverItemId === c._serverItemId) {
981
1588
  self.state.messages.splice(ci + 1, 1);
982
1589
  }
983
1590
  }
984
1591
  }
1592
+ // Re-stamp a cancel that is still in flight (see inFlightCancel above).
1593
+ // Only onto a bubble the server still reports as pending — once it has
1594
+ // settled the flags describe nothing the user can act on.
1595
+ for (var fi = 0; fi < self.state.messages.length; fi++) {
1596
+ var fm = self.state.messages[fi];
1597
+ var was = fm._serverItemId && inFlightCancel[fm._serverItemId];
1598
+ if (!was || fm.isCancelled) continue;
1599
+ if (!(fm.isPendingQueued || fm.isPendingInProcess || fm.isPending)) continue;
1600
+ if (was._cancelling) fm._cancelling = true;
1601
+ if (was._cancelError) fm._cancelError = was._cancelError;
1602
+ }
985
1603
  }
986
- self.state.historyEndOfList = !!(history && history.endOfList);
987
- self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
988
- var clearedAt = self.host.getClearedAt();
989
- if (clearedAt && chatList.length > 0) {
990
- var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
991
- if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
1604
+ // Only adopt page 1's cursor when page 1 IS the whole list. If older pages
1605
+ // survived the merge, rewinding here would make the next scroll-up
1606
+ // re-fetch page 2 (already on screen) and duplicate it.
1607
+ if (!keptOlderPages) {
1608
+ self.state.historyEndOfList = !!(history && history.endOfList);
1609
+ self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
1610
+ var clearedAt = self.host.getClearedAt();
1611
+ if (clearedAt && chatList.length > 0) {
1612
+ var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
1613
+ if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
1614
+ }
992
1615
  }
993
1616
  // Clear loading flags BEFORE this render so the final paint is
994
1617
  // indicator-free (and the view's scroll-restore math sees matching heights).
@@ -1022,22 +1645,42 @@ export class ChatSession {
1022
1645
  // dispatch never polls, so they MUST still get their poll — skipping
1023
1646
  // them was the stranded-"Thinking" bug this guard must not revive.
1024
1647
  if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
1025
- self.historyItemPolls.set(item.id, true);
1648
+ // Background indexing polls are suppressed while paused; foreground
1649
+ // replies the user is waiting on are not.
1650
+ if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
1026
1651
  var capturedId = item.id;
1027
1652
  var pp = item.poll({
1028
1653
  latency: POLL_INTERVAL,
1029
- onResponse: function (response: any) { self.handleHistoryItemResolution(capturedId, response, platform); },
1654
+ onResponse: function (response: any) { if (isPollStopped(response)) return; self.handleHistoryItemResolution(capturedId, response, platform); },
1030
1655
  onError: function (err: any) {
1031
1656
  self.historyItemPolls.delete(capturedId);
1032
1657
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
1033
1658
  var aIdx = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
1034
1659
  if (isNotExists) {
1035
- var isBg = aIdx !== -1 ? !!self.state.messages[aIdx].isBackgroundTask : false;
1660
+ var uIdx = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId && !m.isCancelled; });
1661
+ // Decide bg-ness from whichever bubble actually exists. A
1662
+ // QUEUED pass has no assistant placeholder (history.ts emits
1663
+ // none), so reading it off the placeholder alone classified
1664
+ // every queued indexing pass as foreground: the rebuild below
1665
+ // then dropped isBackgroundTask/_indexFile, ejecting the pass
1666
+ // from its collapsed row as a raw "Indexing: <file>" chat
1667
+ // bubble, and promoteNextQueuedToRunning span up a "Thinking..."
1668
+ // on an unrelated queued FOREGROUND message.
1669
+ var isBg = (aIdx !== -1 && !!self.state.messages[aIdx].isBackgroundTask)
1670
+ || (uIdx !== -1 && !!self.state.messages[uIdx].isBackgroundTask);
1036
1671
  if (aIdx !== -1) self.state.messages.splice(aIdx, 1);
1037
1672
  if (!isBg) {
1038
- var uIdx = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId && !m.isCancelled; });
1039
1673
  if (uIdx !== -1) { var ex = self.state.messages[uIdx]; self.state.messages[uIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId }; }
1040
1674
  self.cancelledServerIds.delete(capturedId); self.promoteNextQueuedToRunning();
1675
+ } else if (uIdx !== -1) {
1676
+ // Same carry-over every other rebuild does, so the pass stays
1677
+ // a member of its file's row while reading as cancelled.
1678
+ var bex = self.state.messages[uIdx];
1679
+ var bcancelled: ChatMessage = { role: 'user', content: bex.content, isCancelled: true, isBackgroundTask: true, _serverItemId: bex._serverItemId };
1680
+ if (bex._indexFile) bcancelled._indexFile = bex._indexFile;
1681
+ if (bex._useBgQueue) bcancelled._useBgQueue = true;
1682
+ self.state.messages[uIdx] = bcancelled;
1683
+ self.promoteNextBgQueuedToRunning();
1041
1684
  }
1042
1685
  self.host.notify(); self.updateHistoryCache(); return;
1043
1686
  }
@@ -1049,12 +1692,22 @@ export class ChatSession {
1049
1692
  }
1050
1693
  },
1051
1694
  });
1695
+ // Anything on the BACKGROUND queue is pausable, not just items whose
1696
+ // prompt text we recognise as an indexing task. _isBgTask vs
1697
+ // _isOnBgQueue is a DISPLAY distinction ("Indexing: file" vs a normal
1698
+ // chat bubble); keying the pause off _isBgTask alone left every
1699
+ // bg-queue item we could not text-match polling forever.
1700
+ self._trackPoll(capturedId, (item._isBgTask || item._isOnBgQueue) ? 'bg' : 'fg', pp);
1052
1701
  if (pp && pp.catch) pp.catch(function () { });
1053
1702
  });
1054
1703
  self.drainBgTaskQueue();
1055
1704
  }
1056
1705
 
1057
- if (!fetchMore) return self.host.scrollToBottom();
1706
+ // Sticky, NOT forcing: this runs after every first-page load, including
1707
+ // the one resumePolling fires on visibilitychange. Forcing yanked a
1708
+ // reader who had scrolled up back to the bottom. On a genuine mount the
1709
+ // user is still pinned, so this behaves identically there.
1710
+ if (!fetchMore) return self.host.scrollToBottomIfSticky();
1058
1711
  }).catch(function (err: any) {
1059
1712
  console.warn('[chat-engine] getChatHistory failed', err);
1060
1713
  }).then(function () {
@@ -1063,6 +1716,12 @@ export class ChatSession {
1063
1716
  self.state.loadingHistory = false; self.state.loadingOlderHistory = false;
1064
1717
  if (wasLoading) self.host.notify();
1065
1718
  }
1719
+ // Every first-page load ends here — mount, resumePolling, a cache-restore
1720
+ // refresh — and every one of them can leave a box too short to scroll,
1721
+ // which is the only trigger there is for reaching page 2. The view (which
1722
+ // alone can measure) pages its way out of it. Fired after the flags are
1723
+ // cleared so the fill loop does not see a request still in flight.
1724
+ if (self.host.onHistoryLoaded) self.host.onHistoryLoaded(!!fetchMore, token as number);
1066
1725
  });
1067
1726
  }
1068
1727