bunnyquery 1.6.2 → 1.8.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.
@@ -34,13 +34,16 @@ import {
34
34
  OPENAI_RESPONSES_API_URL,
35
35
  type BgTaskEntry,
36
36
  } from './requests';
37
- import { isPagedReadFile, isImageVisionFile } 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';
43
+ import { wallClockNow } from './time';
42
44
  import { parseAttachmentContent } from './attachment_parsers';
43
45
  import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
46
+ import type { IndexingGroup } from './indexing_groups';
44
47
 
45
48
  function sleep(ms: number): Promise<void> {
46
49
  return new Promise(function (r) { setTimeout(r, ms); });
@@ -79,6 +82,13 @@ export class ChatSession {
79
82
  state: ChatState;
80
83
  bgTaskQueue: BgTaskEntry[];
81
84
  cancelledServerIds: Set<string>;
85
+ /** Files whose indexing the user stopped, keyed exactly as buildChatDisplayList
86
+ * keys a group (storage path, else filename). Cancelling one pass is not
87
+ * enough on its own: the client dispatches CONTINUE passes for paged files, so
88
+ * without this the next pass is enqueued the moment the cancelled one settles.
89
+ * Cleared when a FIRST pass for the same file is queued again (a re-upload or a
90
+ * Reindex from the file manager), so stopping a file never poisons it. */
91
+ cancelledIndexKeys: Set<string>;
82
92
  pendingAgentRequests: Record<string, Promise<any>>;
83
93
  aiChatHistoryCache: Record<string, { messages: ChatMessage[]; endOfList: boolean; startKeyHistory: string[] }>;
84
94
  historyItemPolls: Map<string, PollHandle>;
@@ -106,6 +116,7 @@ export class ChatSession {
106
116
  };
107
117
  this.bgTaskQueue = [];
108
118
  this.cancelledServerIds = new Set();
119
+ this.cancelledIndexKeys = new Set();
109
120
  this.pendingAgentRequests = {};
110
121
  this.aiChatHistoryCache = {};
111
122
  this.historyItemPolls = new Map();
@@ -132,6 +143,21 @@ export class ChatSession {
132
143
  return p;
133
144
  }
134
145
 
146
+ /**
147
+ * Stop and forget one item's poll. Used after a cancel: the row is either gone
148
+ * (cancelled while queued) or flagged cancelled (cancelled while running), so
149
+ * asking about it again only burns requests. Safe when no poll is attached, and
150
+ * safe on an older skapi-js with no stop handle (the entry is then LEFT in the
151
+ * map so a later drain cannot stack a second, unstoppable poll on the id).
152
+ */
153
+ private _stopPoll(id: string): void {
154
+ var handle = this.historyItemPolls.get(id);
155
+ if (!handle) return;
156
+ if (typeof handle.stop !== 'function') return;
157
+ try { handle.stop(); } catch (e) { /* best effort */ }
158
+ this.historyItemPolls.delete(id);
159
+ }
160
+
135
161
  /** True while any pause reason is active. */
136
162
  isPollingPaused(): boolean {
137
163
  return this._pauseReasons.size > 0;
@@ -249,7 +275,21 @@ export class ChatSession {
249
275
  if (reply._serverItemId === undefined && msgs[thIdx]._serverItemId !== undefined) reply._serverItemId = msgs[thIdx]._serverItemId;
250
276
  msgs[thIdx] = reply;
251
277
  } else {
252
- msgs.push(reply);
278
+ // No pending placeholder left to replace. Before appending, check whether
279
+ // this turn is ALREADY in the cache — a history fetch may have mapped the
280
+ // resolved turn in while the reply was in flight. Appending blindly is how
281
+ // one turn ended up rendered twice (the duplicated question+answer): the
282
+ // cache is restored verbatim on the next mount, so a duplicate written
283
+ // here survives every later visit.
284
+ var dupIdx = -1;
285
+ if (serverId) {
286
+ for (var d = msgs.length - 1; d >= 0; d--) {
287
+ var dm = msgs[d];
288
+ if (dm && dm.role === 'assistant' && dm._serverItemId === serverId) { dupIdx = d; break; }
289
+ }
290
+ }
291
+ if (dupIdx !== -1) msgs[dupIdx] = reply;
292
+ else msgs.push(reply);
253
293
  }
254
294
 
255
295
  // Settle the user bubble this turn belongs to (first pending one, or the
@@ -438,7 +478,7 @@ export class ChatSession {
438
478
  var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
439
479
  this.aiChatHistoryCache[key] = {
440
480
  messages: offExisting.messages.concat([
441
- { role: 'user', content: composed, _ownerKey: key },
481
+ { role: 'user', content: composed, _ownerKey: key, _ts: wallClockNow() },
442
482
  { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
443
483
  ]),
444
484
  endOfList: offExisting.endOfList,
@@ -461,7 +501,7 @@ export class ChatSession {
461
501
  platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
462
502
  history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
463
503
  });
464
- var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
504
+ var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
465
505
  if (key) queuedBubble._ownerKey = key;
466
506
  if (useBgQueue) queuedBubble._useBgQueue = true;
467
507
  this.state.messages.push(queuedBubble);
@@ -503,7 +543,7 @@ export class ChatSession {
503
543
  // view unmount), then rendered from the cache via typewriteLatestReply. A
504
544
  // later resumePendingRequest() re-renders it if the view remounted while the
505
545
  // request was still in flight.
506
- this.state.messages.push({ role: 'user', content: composed, ...(key ? { _ownerKey: key } : {}) });
546
+ this.state.messages.push({ role: 'user', content: composed, _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) });
507
547
  this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
508
548
  this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
509
549
 
@@ -546,7 +586,7 @@ export class ChatSession {
546
586
  // under `key`, so a later loadChatHistory renders it.
547
587
  self.state.sending = false;
548
588
  if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
549
- return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottom(true); });
589
+ return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
550
590
  });
551
591
  }
552
592
 
@@ -558,6 +598,8 @@ export class ChatSession {
558
598
  if (nextIdx === -1) return;
559
599
  var existing = this.state.messages[nextIdx];
560
600
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
601
+ if (existing._indexFile) promoted._indexFile = existing._indexFile;
602
+ if (existing._ts !== undefined) promoted._ts = existing._ts;
561
603
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
562
604
  if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
563
605
  this.state.messages[nextIdx] = promoted;
@@ -577,6 +619,8 @@ export class ChatSession {
577
619
  var existing = this.state.messages[nextIdx];
578
620
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
579
621
  if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
622
+ if (existing._indexFile) promoted._indexFile = existing._indexFile;
623
+ if (existing._ts !== undefined) promoted._ts = existing._ts;
580
624
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
581
625
  if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
582
626
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
@@ -636,6 +680,7 @@ export class ChatSession {
636
680
  var repl: ChatMessage = { role: 'user', content: exist.content };
637
681
  if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
638
682
  if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
683
+ if (exist._ts !== undefined) repl._ts = exist._ts;
639
684
  this.state.messages[userIdx] = repl;
640
685
  }
641
686
  var thinkingIdx = userIdx >= 0
@@ -645,6 +690,9 @@ export class ChatSession {
645
690
  }
646
691
 
647
692
  insertAtTarget(msg: ChatMessage, targetIdx: number): void {
693
+ // Error/direct replies land here rather than through the typewriter, so this
694
+ // is where they pick up their display time.
695
+ if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = wallClockNow();
648
696
  if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) this.state.messages[targetIdx] = msg;
649
697
  else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
650
698
  else this.state.messages.push(msg);
@@ -689,7 +737,9 @@ export class ChatSession {
689
737
  this.promoteNextQueuedToRunning();
690
738
  this.updateHistoryCache();
691
739
  this.host.notify();
692
- this.host.scrollToBottom(true);
740
+ // ARRIVAL, not a user action: only follow if the user is still pinned to
741
+ // the bottom. Scrolled-up readers keep their position.
742
+ this.host.scrollToBottomIfSticky(true);
693
743
  }
694
744
 
695
745
  onQueuedSendError(_composed: string, err: any, serverId?: string, ownerKey?: string): void {
@@ -732,14 +782,14 @@ export class ChatSession {
732
782
  }
733
783
  if (serverId) this.cancelledServerIds.delete(serverId);
734
784
  this._removeStrayPendingAssistants();
735
- this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
785
+ this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottomIfSticky(true);
736
786
  return;
737
787
  }
738
788
  var targetIdx = this.resolveQueuedUserBubble(serverId);
739
789
  if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
740
790
  this.insertAtTarget({ role: 'assistant', content: getErrorMessage(err), isError: true }, targetIdx);
741
791
  this._removeStrayPendingAssistants();
742
- this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
792
+ this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottomIfSticky(true);
743
793
  }
744
794
 
745
795
  cancelQueuedMessage(msg: ChatMessage, idx: number): void {
@@ -759,13 +809,25 @@ export class ChatSession {
759
809
  })).then(function (result: any) {
760
810
  if (result && result.removed) {
761
811
  self.cancelledServerIds.add(serverId as string);
812
+ self._stopPoll(serverId as string);
762
813
  var qi = self.bgTaskQueue.findIndex(function (e) { return e.id === serverId; });
763
814
  if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
764
815
  var removeIdx = self.state.messages.findIndex(function (m) {
765
816
  return m._serverItemId === serverId && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user';
766
817
  });
767
818
  if (removeIdx !== -1) {
768
- self.state.messages[removeIdx] = { role: 'user', content: self.state.messages[removeIdx].content, isCancelled: true, _serverItemId: serverId };
819
+ // Carry the background/file markers onto the rebuild. Dropping them
820
+ // took a cancelled INDEXING pass out of its file's collapsed row
821
+ // (buildChatDisplayList only groups isBackgroundTask bubbles), so the
822
+ // row stayed "Indexing…" forever while a bare "Indexing: file" bubble
823
+ // appeared beside it.
824
+ var wasMsg = self.state.messages[removeIdx];
825
+ var cancelledMsg: ChatMessage = { role: 'user', content: wasMsg.content, isCancelled: true, _serverItemId: serverId };
826
+ if (wasMsg.isBackgroundTask) cancelledMsg.isBackgroundTask = true;
827
+ if (wasMsg._indexFile) cancelledMsg._indexFile = wasMsg._indexFile;
828
+ if (wasMsg._useBgQueue) cancelledMsg._useBgQueue = true;
829
+ if (wasMsg._ownerKey !== undefined) cancelledMsg._ownerKey = wasMsg._ownerKey;
830
+ self.state.messages[removeIdx] = cancelledMsg;
769
831
  var thById = self.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.isPending && m.role === 'assistant'; });
770
832
  if (thById !== -1) self.state.messages.splice(thById, 1);
771
833
  else {
@@ -791,6 +853,47 @@ export class ChatSession {
791
853
  });
792
854
  }
793
855
 
856
+ /**
857
+ * Stop indexing a file, from its collapsed row — every pass at once, not just
858
+ * the bubble the user happens to see.
859
+ *
860
+ * A big file is indexed as a CHAIN of passes, so cancelling only the live one
861
+ * accomplishes nothing: the next pass is dispatched as soon as it settles.
862
+ * Three things end the chain:
863
+ * 1. every queued/running pass of this file is cancelled server-side
864
+ * (csr-cancel deletes a queued row and flags a running one "cancelled",
865
+ * which is also the worker's gate for NOT enqueueing the next window);
866
+ * 2. the file is remembered in cancelledIndexKeys, so the client-driven
867
+ * resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
868
+ * 3. any of its passes still sitting in bgTaskQueue is dropped by the next
869
+ * drain rather than surfacing a fresh "Indexing…" bubble.
870
+ *
871
+ * Records already written by the passes that DID run are kept — this stops the
872
+ * work, it does not undo it.
873
+ */
874
+ cancelIndexingGroup(group: IndexingGroup): void {
875
+ var self = this;
876
+ if (!group || !group.key) return;
877
+ // The group belongs to the chat on screen, so scope its key the same way
878
+ // _indexKeyOf scopes a queued task's.
879
+ var scoped = this.getHistoryCacheKey() + '|' + group.key;
880
+ this.cancelledIndexKeys.add(scoped);
881
+ var ids = group.cancellableIds || [];
882
+ if (!ids.length) { this.host.notify(); return; }
883
+ ids.forEach(function (serverId) {
884
+ // Address the message by IDENTITY, not by the index captured when the
885
+ // display list was built: an earlier cancel in this same loop may already
886
+ // have spliced the array (a group can have more than one live pass), and a
887
+ // stale index would mark the WRONG bubble as cancelling.
888
+ var idx = self.state.messages.findIndex(function (m) {
889
+ return m._serverItemId === serverId && m.role === 'user' &&
890
+ (m.isPendingQueued || m.isPendingInProcess);
891
+ });
892
+ if (idx === -1) return;
893
+ self.cancelQueuedMessage(self.state.messages[idx], idx);
894
+ });
895
+ }
896
+
794
897
  // --- typewriter -------------------------------------------------------
795
898
  // Reveal `fullText` into a message bubble at a constant wall-clock RATE
796
899
  // (chars/second) driven by requestAnimationFrame, rather than a fixed number
@@ -904,6 +1007,12 @@ export class ChatSession {
904
1007
  private typewriterQueue: Promise<any> = Promise.resolve();
905
1008
  enqueueTypewrite(idx: number, fullText: string, localId?: string): Promise<any> {
906
1009
  var self = this;
1010
+ // Stamp the reply bubble's display time as it starts revealing. This is the
1011
+ // single chokepoint every typed reply (immediate, queued, and bg-resolution)
1012
+ // passes through, so it is where a live reply gets its "when it arrived"
1013
+ // timestamp; a history reload later replaces it with the server `updated`.
1014
+ var target = this.state.messages[idx];
1015
+ if (target && target._ts === undefined) target._ts = wallClockNow();
907
1016
  this.typewriterQueue = this.typewriterQueue.then(function () { return self.typewriteIntoIndex(idx, fullText, localId); });
908
1017
  return this.typewriterQueue;
909
1018
  }
@@ -972,6 +1081,10 @@ export class ChatSession {
972
1081
  var u = this.state.messages[uIdx];
973
1082
  var cleaned: ChatMessage = { role: 'user', content: u.content, _serverItemId: itemId };
974
1083
  if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
1084
+ if (u._ts !== undefined) cleaned._ts = u._ts;
1085
+ // Carry the file ref: it is what keeps this pass in its file's collapsed
1086
+ // row (the label parser is only a fallback for older cached bubbles).
1087
+ if (u._indexFile) cleaned._indexFile = u._indexFile;
975
1088
  this.state.messages[uIdx] = cleaned;
976
1089
  }
977
1090
 
@@ -988,11 +1101,11 @@ export class ChatSession {
988
1101
  if (!pending) return Promise.resolve();
989
1102
  if (this.state.messages.some(function (m) { return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue; })) return Promise.resolve();
990
1103
  this.state.sending = true;
991
- this.host.scrollToBottom(true);
1104
+ this.host.scrollToBottomIfSticky(true);
992
1105
  return Promise.resolve(pending).catch(function () { }).then(function () {
993
1106
  if (token !== self.state.gateRefreshToken) return;
994
1107
  self.state.sending = false;
995
- return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottom(true); });
1108
+ return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
996
1109
  });
997
1110
  }
998
1111
 
@@ -1016,13 +1129,35 @@ export class ChatSession {
1016
1129
  // bubble stuck pending — and drainBgTaskQueue then never clears its queue
1017
1130
  // entry (its stillPending check stays true). Un-pend it here too.
1018
1131
  this._clearPendingUserBubble(itemId);
1132
+ // Carry isBackgroundTask onto the REPLACEMENT. These rebuilds are fresh
1133
+ // object literals, so anything not copied is lost — and dropping this flag
1134
+ // made a pass that resolved LIVE fall out of its file's collapsed row
1135
+ // (buildChatDisplayList only considers isBackgroundTask messages), while
1136
+ // the same pass rebuilt from history stayed grouped. That mismatch is the
1137
+ // "new indexing response renders outside the group" bug.
1138
+ var wasBgTask = !!this.state.messages[idx].isBackgroundTask;
1019
1139
  if (isErr) {
1020
1140
  this.state.messages[idx] = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
1141
+ if (wasBgTask) this.state.messages[idx].isBackgroundTask = true;
1142
+ this.host.notify(); this.updateHistoryCache(); return;
1143
+ }
1144
+ var text = answer || 'No text response received from AI provider.';
1145
+ // A background-indexing reply is INSIDE a collapsed row: nothing of it is
1146
+ // on screen. Typewriting it anyway put it on the one shared, serial
1147
+ // typewriter queue, so the user's own next reply — which IS on screen —
1148
+ // lost its "Thinking..." spinner and sat as an EMPTY bubble for however
1149
+ // long the invisible indexing summary took to reveal (seconds per pass,
1150
+ // additive across files). It also held state.typing true for the whole
1151
+ // time, which disables the platform/model pickers and no-ops Clear
1152
+ // history, and ran a per-frame scroll for content nobody can see.
1153
+ // Write it straight in; expanding the row then shows it complete.
1154
+ if (wasBgTask) {
1155
+ this.state.messages[idx] = { role: 'assistant', content: text, isBackgroundTask: true, _serverItemId: itemId };
1021
1156
  this.host.notify(); this.updateHistoryCache(); return;
1022
1157
  }
1023
1158
  var lid = this._newLocalId();
1024
1159
  this.state.messages[idx] = { role: 'assistant', content: '', _localId: lid, _serverItemId: itemId };
1025
- this.host.notify(); this.enqueueTypewrite(idx, answer || 'No text response received from AI provider.', lid);
1160
+ this.host.notify(); this.enqueueTypewrite(idx, text, lid);
1026
1161
  this.updateHistoryCache(); return;
1027
1162
  }
1028
1163
  var userIdx = this.state.messages.findIndex(function (m) {
@@ -1030,23 +1165,126 @@ export class ChatSession {
1030
1165
  });
1031
1166
  if (userIdx === -1) return;
1032
1167
  var ex = this.state.messages[userIdx];
1033
- this.state.messages[userIdx] = { role: 'user', content: ex.content, _serverItemId: itemId };
1168
+ // Same carry-over as above, plus _indexFile / _useBgQueue: this rebuild is
1169
+ // what keeps an indexing REQUEST bubble in its file's collapsed row (and a
1170
+ // bg-queued chat cancelling against the right queue) once the pass settles.
1171
+ var settledUser: ChatMessage = { role: 'user', content: ex.content, _serverItemId: itemId };
1172
+ if (ex.isBackgroundTask) settledUser.isBackgroundTask = true;
1173
+ if (ex._ts !== undefined) settledUser._ts = ex._ts;
1174
+ if (ex._indexFile) settledUser._indexFile = ex._indexFile;
1175
+ if (ex._useBgQueue) settledUser._useBgQueue = true;
1176
+ this.state.messages[userIdx] = settledUser;
1034
1177
  if (isErr) {
1035
- this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: answer, isError: true, _serverItemId: itemId });
1178
+ var errReply: ChatMessage = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
1179
+ if (ex.isBackgroundTask) errReply.isBackgroundTask = true;
1180
+ this.state.messages.splice(userIdx + 1, 0, errReply);
1181
+ this.host.notify(); this.updateHistoryCache(); return;
1182
+ }
1183
+ var text2 = answer || 'No text response received from AI provider.';
1184
+ // Same as above: a collapsed row's reply is not on screen, so revealing it
1185
+ // character by character only blocks the queue the visible reply needs.
1186
+ if (ex.isBackgroundTask) {
1187
+ this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: text2, isBackgroundTask: true, _serverItemId: itemId });
1036
1188
  this.host.notify(); this.updateHistoryCache(); return;
1037
1189
  }
1038
1190
  var lid2 = this._newLocalId();
1039
- this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: '', _localId: lid2, _serverItemId: itemId });
1040
- this.host.notify(); this.enqueueTypewrite(userIdx + 1, answer || 'No text response received from AI provider.', lid2);
1191
+ var reply: ChatMessage = { role: 'assistant', content: '', _localId: lid2, _serverItemId: itemId };
1192
+ this.state.messages.splice(userIdx + 1, 0, reply);
1193
+ this.host.notify(); this.enqueueTypewrite(userIdx + 1, text2, lid2);
1041
1194
  this.updateHistoryCache();
1042
1195
  }
1043
1196
 
1197
+ /** How a bg task maps onto a collapsed row: the row's own key (storage path
1198
+ * when known, else the filename), scoped to the chat it belongs to. A storage
1199
+ * path is project-relative ("report.xlsx"), and ONE ChatSession serves every
1200
+ * project — unscoped, stopping a file in one project would silently suppress
1201
+ * the same filename's continuations in another. */
1202
+ private _indexKeyOf(entry: BgTaskEntry): string {
1203
+ if (!entry) return '';
1204
+ var file = entry.storagePath || entry.filename;
1205
+ if (!file) return '';
1206
+ return entry.serviceId + '#' + entry.platform + '|' + file;
1207
+ }
1208
+
1209
+ /**
1210
+ * Reconcile the bg queue with the files the user has stopped.
1211
+ *
1212
+ * A FIRST pass (no resumePass) is a fresh indexing request — a re-upload, or a
1213
+ * Reindex from the file manager — so it LIFTS the stop: the key is a storage
1214
+ * path, and without this an earlier cancel would silently kill every future
1215
+ * index of the same path. A continuation of a stopped file is dropped instead,
1216
+ * covering the pass that was dispatched in the moment before the cancel landed.
1217
+ */
1218
+ private _applyIndexCancellations(): void {
1219
+ if (!this.cancelledIndexKeys.size) return;
1220
+ for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
1221
+ var entry = this.bgTaskQueue[i];
1222
+ var key = this._indexKeyOf(entry);
1223
+ if (!key || !this.cancelledIndexKeys.has(key)) continue;
1224
+ if (!entry.resumePass) { this.cancelledIndexKeys.delete(key); continue; }
1225
+ this.bgTaskQueue.splice(i, 1);
1226
+ this._stopPoll(entry.id);
1227
+ this._cancelServerItem(entry.id);
1228
+ }
1229
+ }
1230
+
1231
+ /**
1232
+ * Cancel any live pass of a stopped file that turned up on its own.
1233
+ *
1234
+ * The client is not the only thing that continues a file: for PDFs and (when
1235
+ * windowed indexing is on) text/grid files the WORKER enqueues the next window
1236
+ * itself, and that pass reaches the chat through the history poll, never
1237
+ * through bgTaskQueue. The worker's own gate stops the chain when the running
1238
+ * row is cancelled — but if the row had already finished when the user hit
1239
+ * stop, the next window was queued a moment earlier and still arrives. Stop it
1240
+ * here rather than making the user hit stop again.
1241
+ *
1242
+ * Runs from drainBgTaskQueue, which both clients call after a history load.
1243
+ */
1244
+ private _sweepCancelledIndexing(): void {
1245
+ if (!this.cancelledIndexKeys.size) return;
1246
+ var self = this;
1247
+ var chatKey = this.getHistoryCacheKey();
1248
+ var targets: { msg: ChatMessage; idx: number }[] = [];
1249
+ this.state.messages.forEach(function (m, i) {
1250
+ if (!m.isBackgroundTask || m.role !== 'user' || !m._serverItemId) return;
1251
+ if (m._cancelling || m.isSendingToServer) return;
1252
+ if (!(m.isPendingQueued || m.isPendingInProcess)) return;
1253
+ var ref = m._indexFile;
1254
+ var file = ref && (ref.path || ref.name);
1255
+ if (!file || !self.cancelledIndexKeys.has(chatKey + '|' + file)) return;
1256
+ targets.push({ msg: m, idx: i });
1257
+ });
1258
+ targets.forEach(function (t) {
1259
+ var idx = self.state.messages.indexOf(t.msg);
1260
+ self.cancelQueuedMessage(t.msg, idx === -1 ? t.idx : idx);
1261
+ });
1262
+ }
1263
+
1264
+ /** Best-effort server-side cancel of a bg-queue item that has no bubble (so
1265
+ * cancelQueuedMessage, which drives one, has nothing to act on). */
1266
+ private _cancelServerItem(serverId: string): void {
1267
+ var id = this.host.getIdentity();
1268
+ if (!serverId || (id.platform !== 'claude' && id.platform !== 'openai')) return;
1269
+ var url = id.platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
1270
+ Promise.resolve(this.host.cancelRequest({
1271
+ url: url, method: 'POST', id: serverId,
1272
+ queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
1273
+ service: id.serviceId, owner: id.owner,
1274
+ })).catch(function () { /* the pass may already have finished; nothing to do */ });
1275
+ }
1276
+
1044
1277
  // Inject "Indexing: <file>" bubbles for queued bg tasks + attach their polls.
1045
1278
  drainBgTaskQueue(): void {
1046
1279
  var self = this;
1047
1280
  var id = this.host.getIdentity();
1048
1281
  var svcId = id.serviceId, plat = id.platform;
1049
1282
  if (!svcId || plat === 'none' || !this.host.isViewMounted()) return;
1283
+ // Before anything is surfaced: drop continuations of files the user stopped
1284
+ // (and let a fresh first pass lift the stop), then cancel any worker-queued
1285
+ // pass of a stopped file that reached the chat through history.
1286
+ this._applyIndexCancellations();
1287
+ this._sweepCancelledIndexing();
1050
1288
  // Index messages by _serverItemId ONCE, so the per-entry presence/pending
1051
1289
  // checks below are O(1) instead of a full messages.some() scan each. With a
1052
1290
  // large bg queue (bulk uploads) the old nested scan was O(queue x messages)
@@ -1072,14 +1310,29 @@ export class ChatSession {
1072
1310
  // "Thinking..." once polling resumed.
1073
1311
  if (!presentIds[entry.id]) {
1074
1312
  var isRunning = entry.status === 'running';
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 };
1313
+ var userBubble: ChatMessage = {
1314
+ role: 'user',
1315
+ content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex, !!entry.resumePass),
1316
+ isBackgroundTask: true,
1317
+ _serverItemId: entry.id,
1318
+ // Structured ref so this live pass groups with the same file's passes
1319
+ // rebuilt from history (see indexing_groups.buildChatDisplayList).
1320
+ _indexFile: {
1321
+ name: entry.filename,
1322
+ path: entry.storagePath,
1323
+ mime: entry.mime,
1324
+ size: entry.size,
1325
+ isReindex: !!entry.isReindex,
1326
+ continued: !!entry.resumePass,
1327
+ },
1328
+ };
1076
1329
  if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
1077
1330
  self.state.messages.push(userBubble);
1078
1331
  if (isRunning) {
1079
1332
  self.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
1080
1333
  }
1081
1334
  presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
1082
- self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottom(false);
1335
+ self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottomIfSticky(false);
1083
1336
  }
1084
1337
  if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
1085
1338
  var capturedId = entry.id, capturedPlat = plat;
@@ -1096,12 +1349,28 @@ export class ChatSession {
1096
1349
  }).catch(function (err: any) {
1097
1350
  self.historyItemPolls.delete(capturedId);
1098
1351
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
1352
+ // Settle the REQUEST bubble first, unconditionally. Only a RUNNING
1353
+ // pass gets an assistant placeholder, so for a queued one the lookup
1354
+ // below finds nothing and every branch was skipped — leaving the
1355
+ // request bubble isPendingQueued with its queue entry about to be
1356
+ // spliced away, i.e. no poll left to ever clear it. One such bubble
1357
+ // pins the whole collapsed row to "active": a spinner and a Stop
1358
+ // button against a dead id, until the page is reloaded.
1359
+ self._clearPendingUserBubble(capturedId);
1099
1360
  var bi = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
1100
1361
  if (bi !== -1) {
1101
1362
  if (isNotExists) self.state.messages.splice(bi, 1);
1102
1363
  else self.state.messages[bi] = { role: 'assistant', content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId };
1103
- self.host.notify(); self.updateHistoryCache();
1364
+ } else if (!isNotExists) {
1365
+ // No placeholder to turn into the error (a queued pass). Put one
1366
+ // after the request bubble so the row reports the failure instead
1367
+ // of quietly settling to "Indexed".
1368
+ var ui = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId; });
1369
+ if (ui !== -1) {
1370
+ self.state.messages.splice(ui + 1, 0, { role: 'assistant', content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId });
1371
+ }
1104
1372
  }
1373
+ self.host.notify(); self.updateHistoryCache();
1105
1374
  }).then(function () {
1106
1375
  // Keep the queue entry when the poll was merely stopped, or resuming
1107
1376
  // would have nothing left to re-attach to.
@@ -1130,8 +1399,18 @@ export class ChatSession {
1130
1399
  var self = this;
1131
1400
  try {
1132
1401
  if (!entry || !entry.storagePath) return;
1402
+ // The user stopped this file from its collapsed row. Dispatching the next
1403
+ // pass here is exactly what "stop" has to prevent — the cancelled pass
1404
+ // settles, and without this the chain simply carries on.
1405
+ if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
1133
1406
  if (!isPagedReadFile(entry.filename, entry.mime)) return;
1134
- if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop
1407
+ if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop (PDF vision)
1408
+ // When windowed indexing is on, the WORKER drives the text/grid loop too. The
1409
+ // client MUST NOT also resume, or two drivers each enqueue a continuation per
1410
+ // pass and the chain FORKS - duplicate records, runaway passes. Same reason
1411
+ // PDFs early-return above. Gated on the flag so the old client-driven path is
1412
+ // untouched when windowing is off.
1413
+ if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
1135
1414
  if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
1136
1415
  var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
1137
1416
  if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
@@ -1221,6 +1500,10 @@ export class ChatSession {
1221
1500
  formatIndexingLabel: self.host.formatIndexingLabel,
1222
1501
  }).messages;
1223
1502
 
1503
+ // Set when a first-page refresh re-prepended already-loaded older pages,
1504
+ // so the cursor reset below knows not to rewind to page 1's boundary.
1505
+ var keptOlderPages = false;
1506
+
1224
1507
  if (fetchMore) {
1225
1508
  self.state.messages = mapped.concat(self.state.messages);
1226
1509
  } else {
@@ -1228,7 +1511,17 @@ export class ChatSession {
1228
1511
  var serverIds: any = {};
1229
1512
  mapped.forEach(function (m: any) { if (m._serverItemId) serverIds[m._serverItemId] = 1; });
1230
1513
  var locallyCancelled: any = {};
1231
- self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = 1; });
1514
+ self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = m; });
1515
+ // A cancel the server has not acknowledged yet. Without carrying these
1516
+ // across the replace, a collapsed row mid-cancel drops its "Stopping..."
1517
+ // state and re-offers Stop; the second click cancels an id that is
1518
+ // already gone and writes a bogus "Could not remove from queue." onto a
1519
+ // run that WAS stopped successfully.
1520
+ var inFlightCancel: any = {};
1521
+ self.state.messages.forEach(function (m) {
1522
+ if (!m._serverItemId) return;
1523
+ if (m._cancelling || m._cancelError) inFlightCancel[m._serverItemId] = m;
1524
+ });
1232
1525
  // If the freshly-mapped server list ALREADY shows this in-flight turn
1233
1526
  // as a pending placeholder (a non-bg pending assistant, which carries
1234
1527
  // a real _serverItemId), re-pushing the local no-_serverItemId
@@ -1257,25 +1550,83 @@ export class ChatSession {
1257
1550
  }
1258
1551
  }
1259
1552
  }
1260
- self.state.messages = mapped;
1553
+ // PRESERVE already-loaded older pages. `mapped` is only page 1, so a
1554
+ // blind replace threw away every page the user had scrolled in. This
1555
+ // path runs from resumePolling (visibilitychange), so merely leaving
1556
+ // the tab and coming back wiped the whole scrolled-in history.
1557
+ //
1558
+ // The list is oldest-first (fetchMore PREPENDS), and ids sort with
1559
+ // newest largest, so anything already in the list whose _serverItemId
1560
+ // sorts BELOW page 1's oldest id is an older page. Those are disjoint
1561
+ // from `mapped` (strict <), so re-prepending cannot duplicate.
1562
+ var oldestInPage1: string | undefined = undefined;
1563
+ mapped.forEach(function (m: any) {
1564
+ var sid = m._serverItemId;
1565
+ if (typeof sid !== 'string') return;
1566
+ if (oldestInPage1 === undefined || sid < (oldestInPage1 as string)) oldestInPage1 = sid;
1567
+ });
1568
+ // Only merge when the current list is genuinely a continuation of THIS
1569
+ // page 1 (shares at least one id with it). A project switch, a cleared
1570
+ // horizon, or a wholly-new page means the head is unrelated history and
1571
+ // must be replaced, not spliced.
1572
+ var sharesPage1 = self.state.messages.some(function (m) {
1573
+ return typeof m._serverItemId === 'string' && !!serverIds[m._serverItemId as string];
1574
+ });
1575
+ var retainedOlder: ChatMessage[] = (!sharesPage1 || oldestInPage1 === undefined) ? [] : self.state.messages.filter(function (m) {
1576
+ // Settled server history only; in-flight bubbles are the rescue
1577
+ // loop's job below and would otherwise be kept twice.
1578
+ if (typeof m._serverItemId !== 'string') return false;
1579
+ // Same ownership predicate as the rescue loop: never carry another
1580
+ // project's bubbles onto this chat (the cross-project leak fix).
1581
+ // Compare against the snapshotted loadKey, never a live read.
1582
+ if (m._ownerKey !== undefined && m._ownerKey !== loadKey) return false;
1583
+ return (m._serverItemId as string) < (oldestInPage1 as string);
1584
+ });
1585
+ keptOlderPages = retainedOlder.length > 0;
1586
+ self.state.messages = keptOlderPages ? retainedOlder.concat(mapped) : mapped;
1261
1587
  rescued.forEach(function (m) { self.state.messages.push(m); });
1262
1588
  if (Object.keys(locallyCancelled).length) {
1263
1589
  for (var ci = 0; ci < self.state.messages.length; ci++) {
1264
1590
  var c = self.state.messages[ci];
1265
1591
  if (!c._serverItemId || !locallyCancelled[c._serverItemId] || c.isCancelled) continue;
1266
- self.state.messages[ci] = { role: 'user', content: c.content, isCancelled: true, _serverItemId: c._serverItemId };
1592
+ // Rebuild FROM the mapped bubble rather than as a bare literal:
1593
+ // dropping isBackgroundTask / _indexFile here ejected a stopped
1594
+ // indexing pass from its file's collapsed row, where it then rendered
1595
+ // as a standalone "Indexing: <file>" chat bubble and left the row
1596
+ // recomputing its passes as if that one had never happened.
1597
+ self.state.messages[ci] = {
1598
+ role: 'user', content: c.content, isCancelled: true, _serverItemId: c._serverItemId,
1599
+ isBackgroundTask: c.isBackgroundTask, _indexFile: c._indexFile,
1600
+ _useBgQueue: c._useBgQueue, _ownerKey: c._ownerKey,
1601
+ };
1267
1602
  if (ci + 1 < self.state.messages.length && self.state.messages[ci + 1].isPending && self.state.messages[ci + 1]._serverItemId === c._serverItemId) {
1268
1603
  self.state.messages.splice(ci + 1, 1);
1269
1604
  }
1270
1605
  }
1271
1606
  }
1607
+ // Re-stamp a cancel that is still in flight (see inFlightCancel above).
1608
+ // Only onto a bubble the server still reports as pending — once it has
1609
+ // settled the flags describe nothing the user can act on.
1610
+ for (var fi = 0; fi < self.state.messages.length; fi++) {
1611
+ var fm = self.state.messages[fi];
1612
+ var was = fm._serverItemId && inFlightCancel[fm._serverItemId];
1613
+ if (!was || fm.isCancelled) continue;
1614
+ if (!(fm.isPendingQueued || fm.isPendingInProcess || fm.isPending)) continue;
1615
+ if (was._cancelling) fm._cancelling = true;
1616
+ if (was._cancelError) fm._cancelError = was._cancelError;
1617
+ }
1272
1618
  }
1273
- self.state.historyEndOfList = !!(history && history.endOfList);
1274
- self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
1275
- var clearedAt = self.host.getClearedAt();
1276
- if (clearedAt && chatList.length > 0) {
1277
- var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
1278
- if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
1619
+ // Only adopt page 1's cursor when page 1 IS the whole list. If older pages
1620
+ // survived the merge, rewinding here would make the next scroll-up
1621
+ // re-fetch page 2 (already on screen) and duplicate it.
1622
+ if (!keptOlderPages) {
1623
+ self.state.historyEndOfList = !!(history && history.endOfList);
1624
+ self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
1625
+ var clearedAt = self.host.getClearedAt();
1626
+ if (clearedAt && chatList.length > 0) {
1627
+ var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
1628
+ if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
1629
+ }
1279
1630
  }
1280
1631
  // Clear loading flags BEFORE this render so the final paint is
1281
1632
  // indicator-free (and the view's scroll-restore math sees matching heights).
@@ -1321,12 +1672,30 @@ export class ChatSession {
1321
1672
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
1322
1673
  var aIdx = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
1323
1674
  if (isNotExists) {
1324
- var isBg = aIdx !== -1 ? !!self.state.messages[aIdx].isBackgroundTask : false;
1675
+ var uIdx = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId && !m.isCancelled; });
1676
+ // Decide bg-ness from whichever bubble actually exists. A
1677
+ // QUEUED pass has no assistant placeholder (history.ts emits
1678
+ // none), so reading it off the placeholder alone classified
1679
+ // every queued indexing pass as foreground: the rebuild below
1680
+ // then dropped isBackgroundTask/_indexFile, ejecting the pass
1681
+ // from its collapsed row as a raw "Indexing: <file>" chat
1682
+ // bubble, and promoteNextQueuedToRunning span up a "Thinking..."
1683
+ // on an unrelated queued FOREGROUND message.
1684
+ var isBg = (aIdx !== -1 && !!self.state.messages[aIdx].isBackgroundTask)
1685
+ || (uIdx !== -1 && !!self.state.messages[uIdx].isBackgroundTask);
1325
1686
  if (aIdx !== -1) self.state.messages.splice(aIdx, 1);
1326
1687
  if (!isBg) {
1327
- var uIdx = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId && !m.isCancelled; });
1328
1688
  if (uIdx !== -1) { var ex = self.state.messages[uIdx]; self.state.messages[uIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId }; }
1329
1689
  self.cancelledServerIds.delete(capturedId); self.promoteNextQueuedToRunning();
1690
+ } else if (uIdx !== -1) {
1691
+ // Same carry-over every other rebuild does, so the pass stays
1692
+ // a member of its file's row while reading as cancelled.
1693
+ var bex = self.state.messages[uIdx];
1694
+ var bcancelled: ChatMessage = { role: 'user', content: bex.content, isCancelled: true, isBackgroundTask: true, _serverItemId: bex._serverItemId };
1695
+ if (bex._indexFile) bcancelled._indexFile = bex._indexFile;
1696
+ if (bex._useBgQueue) bcancelled._useBgQueue = true;
1697
+ self.state.messages[uIdx] = bcancelled;
1698
+ self.promoteNextBgQueuedToRunning();
1330
1699
  }
1331
1700
  self.host.notify(); self.updateHistoryCache(); return;
1332
1701
  }
@@ -1349,7 +1718,11 @@ export class ChatSession {
1349
1718
  self.drainBgTaskQueue();
1350
1719
  }
1351
1720
 
1352
- if (!fetchMore) return self.host.scrollToBottom();
1721
+ // Sticky, NOT forcing: this runs after every first-page load, including
1722
+ // the one resumePolling fires on visibilitychange. Forcing yanked a
1723
+ // reader who had scrolled up back to the bottom. On a genuine mount the
1724
+ // user is still pinned, so this behaves identically there.
1725
+ if (!fetchMore) return self.host.scrollToBottomIfSticky();
1353
1726
  }).catch(function (err: any) {
1354
1727
  console.warn('[chat-engine] getChatHistory failed', err);
1355
1728
  }).then(function () {
@@ -1358,6 +1731,12 @@ export class ChatSession {
1358
1731
  self.state.loadingHistory = false; self.state.loadingOlderHistory = false;
1359
1732
  if (wasLoading) self.host.notify();
1360
1733
  }
1734
+ // Every first-page load ends here — mount, resumePolling, a cache-restore
1735
+ // refresh — and every one of them can leave a box too short to scroll,
1736
+ // which is the only trigger there is for reaching page 2. The view (which
1737
+ // alone can measure) pages its way out of it. Fired after the flags are
1738
+ // cleared so the fill loop does not see a request still in flight.
1739
+ if (self.host.onHistoryLoaded) self.host.onHistoryLoaded(!!fetchMore, token as number);
1361
1740
  });
1362
1741
  }
1363
1742