bunnyquery 1.6.2 → 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.
@@ -34,13 +34,15 @@ 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';
42
43
  import { parseAttachmentContent } from './attachment_parsers';
43
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); });
@@ -79,6 +81,13 @@ export class ChatSession {
79
81
  state: ChatState;
80
82
  bgTaskQueue: BgTaskEntry[];
81
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>;
82
91
  pendingAgentRequests: Record<string, Promise<any>>;
83
92
  aiChatHistoryCache: Record<string, { messages: ChatMessage[]; endOfList: boolean; startKeyHistory: string[] }>;
84
93
  historyItemPolls: Map<string, PollHandle>;
@@ -106,6 +115,7 @@ export class ChatSession {
106
115
  };
107
116
  this.bgTaskQueue = [];
108
117
  this.cancelledServerIds = new Set();
118
+ this.cancelledIndexKeys = new Set();
109
119
  this.pendingAgentRequests = {};
110
120
  this.aiChatHistoryCache = {};
111
121
  this.historyItemPolls = new Map();
@@ -132,6 +142,21 @@ export class ChatSession {
132
142
  return p;
133
143
  }
134
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
+
135
160
  /** True while any pause reason is active. */
136
161
  isPollingPaused(): boolean {
137
162
  return this._pauseReasons.size > 0;
@@ -249,7 +274,21 @@ export class ChatSession {
249
274
  if (reply._serverItemId === undefined && msgs[thIdx]._serverItemId !== undefined) reply._serverItemId = msgs[thIdx]._serverItemId;
250
275
  msgs[thIdx] = reply;
251
276
  } else {
252
- msgs.push(reply);
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);
253
292
  }
254
293
 
255
294
  // Settle the user bubble this turn belongs to (first pending one, or the
@@ -546,7 +585,7 @@ export class ChatSession {
546
585
  // under `key`, so a later loadChatHistory renders it.
547
586
  self.state.sending = false;
548
587
  if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
549
- return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottom(true); });
588
+ return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
550
589
  });
551
590
  }
552
591
 
@@ -558,6 +597,7 @@ export class ChatSession {
558
597
  if (nextIdx === -1) return;
559
598
  var existing = this.state.messages[nextIdx];
560
599
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
600
+ if (existing._indexFile) promoted._indexFile = existing._indexFile;
561
601
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
562
602
  if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
563
603
  this.state.messages[nextIdx] = promoted;
@@ -577,6 +617,7 @@ export class ChatSession {
577
617
  var existing = this.state.messages[nextIdx];
578
618
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
579
619
  if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
620
+ if (existing._indexFile) promoted._indexFile = existing._indexFile;
580
621
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
581
622
  if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
582
623
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
@@ -689,7 +730,9 @@ export class ChatSession {
689
730
  this.promoteNextQueuedToRunning();
690
731
  this.updateHistoryCache();
691
732
  this.host.notify();
692
- 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);
693
736
  }
694
737
 
695
738
  onQueuedSendError(_composed: string, err: any, serverId?: string, ownerKey?: string): void {
@@ -732,14 +775,14 @@ export class ChatSession {
732
775
  }
733
776
  if (serverId) this.cancelledServerIds.delete(serverId);
734
777
  this._removeStrayPendingAssistants();
735
- this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
778
+ this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottomIfSticky(true);
736
779
  return;
737
780
  }
738
781
  var targetIdx = this.resolveQueuedUserBubble(serverId);
739
782
  if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
740
783
  this.insertAtTarget({ role: 'assistant', content: getErrorMessage(err), isError: true }, targetIdx);
741
784
  this._removeStrayPendingAssistants();
742
- this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
785
+ this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottomIfSticky(true);
743
786
  }
744
787
 
745
788
  cancelQueuedMessage(msg: ChatMessage, idx: number): void {
@@ -759,13 +802,25 @@ export class ChatSession {
759
802
  })).then(function (result: any) {
760
803
  if (result && result.removed) {
761
804
  self.cancelledServerIds.add(serverId as string);
805
+ self._stopPoll(serverId as string);
762
806
  var qi = self.bgTaskQueue.findIndex(function (e) { return e.id === serverId; });
763
807
  if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
764
808
  var removeIdx = self.state.messages.findIndex(function (m) {
765
809
  return m._serverItemId === serverId && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user';
766
810
  });
767
811
  if (removeIdx !== -1) {
768
- 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;
769
824
  var thById = self.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.isPending && m.role === 'assistant'; });
770
825
  if (thById !== -1) self.state.messages.splice(thById, 1);
771
826
  else {
@@ -791,6 +846,47 @@ export class ChatSession {
791
846
  });
792
847
  }
793
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
+
794
890
  // --- typewriter -------------------------------------------------------
795
891
  // Reveal `fullText` into a message bubble at a constant wall-clock RATE
796
892
  // (chars/second) driven by requestAnimationFrame, rather than a fixed number
@@ -972,6 +1068,9 @@ export class ChatSession {
972
1068
  var u = this.state.messages[uIdx];
973
1069
  var cleaned: ChatMessage = { role: 'user', content: u.content, _serverItemId: itemId };
974
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;
975
1074
  this.state.messages[uIdx] = cleaned;
976
1075
  }
977
1076
 
@@ -988,11 +1087,11 @@ export class ChatSession {
988
1087
  if (!pending) return Promise.resolve();
989
1088
  if (this.state.messages.some(function (m) { return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue; })) return Promise.resolve();
990
1089
  this.state.sending = true;
991
- this.host.scrollToBottom(true);
1090
+ this.host.scrollToBottomIfSticky(true);
992
1091
  return Promise.resolve(pending).catch(function () { }).then(function () {
993
1092
  if (token !== self.state.gateRefreshToken) return;
994
1093
  self.state.sending = false;
995
- 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); });
996
1095
  });
997
1096
  }
998
1097
 
@@ -1016,13 +1115,35 @@ export class ChatSession {
1016
1115
  // bubble stuck pending — and drainBgTaskQueue then never clears its queue
1017
1116
  // entry (its stillPending check stays true). Un-pend it here too.
1018
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;
1019
1125
  if (isErr) {
1020
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 };
1021
1142
  this.host.notify(); this.updateHistoryCache(); return;
1022
1143
  }
1023
1144
  var lid = this._newLocalId();
1024
1145
  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);
1146
+ this.host.notify(); this.enqueueTypewrite(idx, text, lid);
1026
1147
  this.updateHistoryCache(); return;
1027
1148
  }
1028
1149
  var userIdx = this.state.messages.findIndex(function (m) {
@@ -1030,23 +1151,125 @@ export class ChatSession {
1030
1151
  });
1031
1152
  if (userIdx === -1) return;
1032
1153
  var ex = this.state.messages[userIdx];
1033
- 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;
1034
1162
  if (isErr) {
1035
- 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 });
1036
1173
  this.host.notify(); this.updateHistoryCache(); return;
1037
1174
  }
1038
1175
  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);
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);
1041
1179
  this.updateHistoryCache();
1042
1180
  }
1043
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
+
1044
1262
  // Inject "Indexing: <file>" bubbles for queued bg tasks + attach their polls.
1045
1263
  drainBgTaskQueue(): void {
1046
1264
  var self = this;
1047
1265
  var id = this.host.getIdentity();
1048
1266
  var svcId = id.serviceId, plat = id.platform;
1049
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();
1050
1273
  // Index messages by _serverItemId ONCE, so the per-entry presence/pending
1051
1274
  // checks below are O(1) instead of a full messages.some() scan each. With a
1052
1275
  // large bg queue (bulk uploads) the old nested scan was O(queue x messages)
@@ -1072,14 +1295,29 @@ export class ChatSession {
1072
1295
  // "Thinking..." once polling resumed.
1073
1296
  if (!presentIds[entry.id]) {
1074
1297
  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 };
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
+ };
1076
1314
  if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
1077
1315
  self.state.messages.push(userBubble);
1078
1316
  if (isRunning) {
1079
1317
  self.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
1080
1318
  }
1081
1319
  presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
1082
- self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottom(false);
1320
+ self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottomIfSticky(false);
1083
1321
  }
1084
1322
  if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
1085
1323
  var capturedId = entry.id, capturedPlat = plat;
@@ -1096,12 +1334,28 @@ export class ChatSession {
1096
1334
  }).catch(function (err: any) {
1097
1335
  self.historyItemPolls.delete(capturedId);
1098
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);
1099
1345
  var bi = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
1100
1346
  if (bi !== -1) {
1101
1347
  if (isNotExists) self.state.messages.splice(bi, 1);
1102
1348
  else self.state.messages[bi] = { role: 'assistant', content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId };
1103
- 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
+ }
1104
1357
  }
1358
+ self.host.notify(); self.updateHistoryCache();
1105
1359
  }).then(function () {
1106
1360
  // Keep the queue entry when the poll was merely stopped, or resuming
1107
1361
  // would have nothing left to re-attach to.
@@ -1130,8 +1384,18 @@ export class ChatSession {
1130
1384
  var self = this;
1131
1385
  try {
1132
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;
1133
1391
  if (!isPagedReadFile(entry.filename, entry.mime)) return;
1134
- if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop
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;
1135
1399
  if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
1136
1400
  var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
1137
1401
  if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
@@ -1221,6 +1485,10 @@ export class ChatSession {
1221
1485
  formatIndexingLabel: self.host.formatIndexingLabel,
1222
1486
  }).messages;
1223
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
+
1224
1492
  if (fetchMore) {
1225
1493
  self.state.messages = mapped.concat(self.state.messages);
1226
1494
  } else {
@@ -1228,7 +1496,17 @@ export class ChatSession {
1228
1496
  var serverIds: any = {};
1229
1497
  mapped.forEach(function (m: any) { if (m._serverItemId) serverIds[m._serverItemId] = 1; });
1230
1498
  var locallyCancelled: any = {};
1231
- 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
+ });
1232
1510
  // If the freshly-mapped server list ALREADY shows this in-flight turn
1233
1511
  // as a pending placeholder (a non-bg pending assistant, which carries
1234
1512
  // a real _serverItemId), re-pushing the local no-_serverItemId
@@ -1257,25 +1535,83 @@ export class ChatSession {
1257
1535
  }
1258
1536
  }
1259
1537
  }
1260
- 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;
1261
1572
  rescued.forEach(function (m) { self.state.messages.push(m); });
1262
1573
  if (Object.keys(locallyCancelled).length) {
1263
1574
  for (var ci = 0; ci < self.state.messages.length; ci++) {
1264
1575
  var c = self.state.messages[ci];
1265
1576
  if (!c._serverItemId || !locallyCancelled[c._serverItemId] || c.isCancelled) continue;
1266
- 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
+ };
1267
1587
  if (ci + 1 < self.state.messages.length && self.state.messages[ci + 1].isPending && self.state.messages[ci + 1]._serverItemId === c._serverItemId) {
1268
1588
  self.state.messages.splice(ci + 1, 1);
1269
1589
  }
1270
1590
  }
1271
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
+ }
1272
1603
  }
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;
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
+ }
1279
1615
  }
1280
1616
  // Clear loading flags BEFORE this render so the final paint is
1281
1617
  // indicator-free (and the view's scroll-restore math sees matching heights).
@@ -1321,12 +1657,30 @@ export class ChatSession {
1321
1657
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
1322
1658
  var aIdx = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
1323
1659
  if (isNotExists) {
1324
- 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);
1325
1671
  if (aIdx !== -1) self.state.messages.splice(aIdx, 1);
1326
1672
  if (!isBg) {
1327
- var uIdx = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId && !m.isCancelled; });
1328
1673
  if (uIdx !== -1) { var ex = self.state.messages[uIdx]; self.state.messages[uIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId }; }
1329
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();
1330
1684
  }
1331
1685
  self.host.notify(); self.updateHistoryCache(); return;
1332
1686
  }
@@ -1349,7 +1703,11 @@ export class ChatSession {
1349
1703
  self.drainBgTaskQueue();
1350
1704
  }
1351
1705
 
1352
- 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();
1353
1711
  }).catch(function (err: any) {
1354
1712
  console.warn('[chat-engine] getChatHistory failed', err);
1355
1713
  }).then(function () {
@@ -1358,6 +1716,12 @@ export class ChatSession {
1358
1716
  self.state.loadingHistory = false; self.state.loadingOlderHistory = false;
1359
1717
  if (wasLoading) self.host.notify();
1360
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);
1361
1725
  });
1362
1726
  }
1363
1727