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.
- package/README.md +157 -29
- package/bunnyquery.css +122 -0
- package/bunnyquery.js +998 -116
- package/dist/engine.cjs +724 -39
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +386 -4
- package/dist/engine.d.ts +386 -4
- package/dist/engine.mjs +712 -40
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/history.ts +20 -2
- package/src/engine/host.ts +28 -1
- package/src/engine/index.ts +24 -1
- package/src/engine/indexing_groups.ts +350 -0
- package/src/engine/links.ts +309 -9
- package/src/engine/prompts/chat_system_prompt.ts +1 -1
- package/src/engine/prompts/indexing_user_message.ts +4 -0
- package/src/engine/requests.ts +1 -1
- package/src/engine/session.ts +394 -30
- package/src/engine/viewport_fill.ts +186 -0
- package/styles/chat.css +122 -0
package/src/engine/session.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1040
|
-
this.
|
|
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 = {
|
|
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.
|
|
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
|
-
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
if (
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|