bunnyquery 1.8.2 → 1.8.4

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.
@@ -23,12 +23,14 @@ import {
23
23
  notifyAgentSaveAttachment,
24
24
  notifyAgentContinueIndexing,
25
25
  INDEXING_COMPLETE_MARKER,
26
+ EMPTY_INDEXING_REPLY,
26
27
  MAX_INDEXING_RESUME_PASSES,
27
28
  extractClaudeText,
28
29
  extractOpenAIText,
29
30
  getChatHistory,
30
31
  POLL_INTERVAL,
31
- BG_INDEXING_QUEUE_SUFFIX,
32
+ MAX_CONCURRENT_BG_POLLS,
33
+ bgIndexingQueueName,
32
34
  isBgIndexingQueue,
33
35
  ANTHROPIC_MESSAGES_API_URL,
34
36
  OPENAI_RESPONSES_API_URL,
@@ -39,10 +41,11 @@ import { windowedIndexingEnabled } from './config';
39
41
  import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
40
42
  import { buildBoundedChatMessages } from './budget';
41
43
  import { createInlineLinkRegex } from './links';
44
+ import { markImagePreviewStale } from './image_preview';
42
45
  import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText } from './history';
43
46
  import { wallClockNow } from './time';
44
47
  import { parseAttachmentContent } from './attachment_parsers';
45
- import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
48
+ import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
46
49
  import type { IndexingGroup } from './indexing_groups';
47
50
 
48
51
  function sleep(ms: number): Promise<void> {
@@ -53,12 +56,63 @@ function sleep(ms: number): Promise<void> {
53
56
  // queue is FIFO per user, so a healthy chain has one running plus at most a
54
57
  // couple queued; the cap only bounds a pathological backlog.
55
58
  const WORKER_PASS_ADOPT_LIMIT = 20;
59
+ // How stale the "which files are indexing" snapshot may be when it is used to
60
+ // refuse a DUPLICATE index dispatch. Short, because the answer decides whether a
61
+ // file gets indexed at all; long enough that one upload batch asks once, not once
62
+ // per file.
63
+ const LIVE_INDEX_SNAPSHOT_MAX_AGE_MS = 5000;
64
+ // How long a client holds a file's indexing slot from the moment it decides to
65
+ // dispatch. It only has to outlive the gap between that decision and the queue
66
+ // admitting the pass (a request, plus the status index catching up); after that
67
+ // the bgTaskQueue entry and the pass's own bubble carry the fact. Bounded so a
68
+ // dispatch that died without releasing cannot lock a file out for the session.
69
+ const INDEX_DISPATCH_CLAIM_MS = 2 * 60 * 1000;
56
70
  // Delays before each look (the first is immediate). More than one because the
57
71
  // worker writes the next pass's row a few milliseconds AFTER it resolves the
58
72
  // current one, so a look that wins that race sees an empty queue for a chain
59
73
  // that is still alive. Fixed and finite: this must never become a poll.
60
74
  const WORKER_PASS_ADOPT_ATTEMPTS = [0, 2000, 6000];
61
75
 
76
+ // awaitIndexingDrained: how often it re-asks the background-indexing queue, and
77
+ // how many consecutive empty answers it needs before believing the chains are
78
+ // over. More than one for the same reason the adopt ladder above looks twice —
79
+ // pass N+1 is written just after pass N resolves, so a single look lands in that
80
+ // gap and reports an idle queue for a file that is still being read.
81
+ //
82
+ // Two cadences: while work is visibly running there is nothing to be gained by
83
+ // asking often (an indexing pass takes tens of seconds, and a big file can hold
84
+ // the queue for many minutes), but once the queue looks empty the confirming
85
+ // look is all that stands between the user and their answer.
86
+ const INDEXING_DRAIN_BUSY_POLL_MS = 8000;
87
+ const INDEXING_DRAIN_CONFIRM_POLL_MS = 3000;
88
+ const INDEXING_DRAIN_IDLE_LOOKS = 2;
89
+ // Floor on the whole wait. The status index is eventually consistent, so the
90
+ // pass this turn's own upload just enqueued can be missing from the first looks —
91
+ // releasing on those would send the chat ahead of the very files it is asking
92
+ // about. Costs nothing in the normal case, where the queue reports work
93
+ // immediately and the wait is far longer than this anyway.
94
+ const INDEXING_DRAIN_MIN_MS = 8000;
95
+ // Ceiling on that wait. Past it the turn is sent regardless: an answer computed
96
+ // against a partly-indexed file is a poor outcome, but a question that is never
97
+ // asked at all because one chain wedged server-side is a worse one.
98
+ const INDEXING_DRAIN_TIMEOUT_MS = 15 * 60 * 1000;
99
+ // A look that never comes back would hang the whole wait forever — there is no
100
+ // pending timer to fall back on, so the turn is never sent and its bubble never
101
+ // stops saying "(Indexing files...)". A look that outlives this is abandoned and
102
+ // counted as UNKNOWN, i.e. as busy, which costs one more cycle and nothing else.
103
+ //
104
+ // Set well above any plausible slow answer rather than just above a fast one. An
105
+ // abandoned look counts as busy and there is no way back: an index that reliably
106
+ // answers slower than this can never produce two agreeing idle looks, so the turn
107
+ // would sit at "(Indexing files...)" until the 15-minute ceiling. The cost of
108
+ // being generous is one extra cycle in the case this is meant to catch, which by
109
+ // definition never returns at all.
110
+ const INDEXING_DRAIN_LOOK_TIMEOUT_MS = 45000;
111
+ // Closest a nudged look may follow the previous one. Bounds the extra request
112
+ // rate when a bulk upload finishes many chains at once; the unnudged cadence is
113
+ // one pair of requests per INDEXING_DRAIN_BUSY_POLL_MS.
114
+ const INDEXING_DRAIN_NUDGE_MIN_GAP_MS = 1500;
115
+
62
116
  // requestAnimationFrame / high-res clock, reached through globalThis so the
63
117
  // engine stays DOM-free at the type level (and degrades gracefully in non-DOM
64
118
  // / test environments where these globals are absent).
@@ -107,6 +161,49 @@ export class ChatSession {
107
161
  private _pauseReasons: Set<string>;
108
162
  private _resuming: boolean;
109
163
  private _lidSeq: number;
164
+ private _stageSeq: number;
165
+ /** How many attachment-upload batches are running. uploadingAttachments is a
166
+ * single flag but batches overlap (the composer stays live, so the user can
167
+ * send a second one while the first uploads), and a nested finish must not
168
+ * clear the flag out from under the batch still running. */
169
+ private _uploadBatches: number;
170
+ /** Indexing requests whose ack has not come back yet. Until it does the item
171
+ * is not on the server's queue, so awaitIndexingDrained cannot see it — and
172
+ * would read the gap between "pass N settled" and "pass N+1 accepted" as the
173
+ * file being finished. */
174
+ private _indexDispatchesInFlight: number;
175
+ /** Live awaitIndexingDrained waiters, one callback each. A nudge only pulls that
176
+ * waiter's NEXT look forward; it can never make one conclude anything, so a
177
+ * wrong nudge costs one pair of requests and the look reports busy. Overlapping
178
+ * waiters are normal — the composer stays live, so a second send can be
179
+ * uploading while the first waits. */
180
+ private _drainNudges: Array<() => void>;
181
+ /** Stages whose upload/dispatch chain is still running in THIS page. Lives and
182
+ * dies with those chains, so it is what tells a staged bubble restored from the
183
+ * history cache whether anything is still working on it (see
184
+ * settleDeadStagedMessages). Today the cache dies with the page too and every
185
+ * restored stage is live; this stays correct if that ever changes. */
186
+ private _liveStages: { [stageId: string]: boolean };
187
+ /** Files the SERVER currently has unresolved indexing work for, by the same key
188
+ * a collapsed row uses (storage path, else filename), and whether we have asked
189
+ * even once for this chat.
190
+ *
191
+ * This is the only thing that can tell a WORKER-driven run (a PDF's page loop, a
192
+ * windowed read) that it is over. Those chains are advanced inside the worker off
193
+ * the renderer's page count; the client sees passes appear and settle and can
194
+ * never tell "between passes" from "finished" by looking at them. Asking the
195
+ * queue is how it finds out. Until it has asked, `checked` is false and the view
196
+ * says "still working", which is the honest reading of not knowing — and the one
197
+ * that does not repeat the bug where a row claimed "Indexed" mid-run. */
198
+ /** The chat the live-index snapshot (state.liveIndexKeys) was taken for, so a
199
+ * project switch drops it. */
200
+ private _liveIndexKey: string;
201
+ /** When the snapshot was last published (wall clock ms), so a caller that needs
202
+ * a CURRENT answer can tell whether to re-ask. 0 = never. */
203
+ private _liveIndexAt: number;
204
+ /** Files this client has an index dispatch in flight for, by scoped path ->
205
+ * wall clock. See claimIndexRun. */
206
+ private _indexClaims: { [scopedPath: string]: number };
110
207
 
111
208
  constructor(host: ChatHost) {
112
209
  this.host = host;
@@ -123,6 +220,9 @@ export class ChatSession {
123
220
  historyStartKeyHistory: [],
124
221
  historyRequestToken: 0,
125
222
  gateRefreshToken: 0,
223
+ liveIndexKeys: {},
224
+ liveIndexChecked: false,
225
+ stoppedIndexIds: {},
126
226
  };
127
227
  this.bgTaskQueue = [];
128
228
  this.cancelledServerIds = new Set();
@@ -133,6 +233,340 @@ export class ChatSession {
133
233
  this._pauseReasons = new Set();
134
234
  this._resuming = false;
135
235
  this._lidSeq = 0;
236
+ this._stageSeq = 0;
237
+ this._uploadBatches = 0;
238
+ this._indexDispatchesInFlight = 0;
239
+ this._drainNudges = [];
240
+ this._liveStages = {};
241
+ this._liveIndexKey = '';
242
+ this._liveIndexAt = 0;
243
+ this._indexClaims = {};
244
+ }
245
+
246
+ /** What the display layer needs to decide whether a run is finished. `keys` holds
247
+ * every file the server still has indexing work for; `checked` is false until the
248
+ * first answer for this chat, and false means "we do not know yet". */
249
+ getLiveIndexState(): { keys: { [fileKey: string]: boolean }; checked: boolean } {
250
+ return { keys: this.state.liveIndexKeys, checked: this.state.liveIndexChecked };
251
+ }
252
+
253
+ /** Passes that were on a row when the user stopped it, so the display layer can
254
+ * still tell that this run was stopped once the stop has left no other trace.
255
+ * See cancelIndexingGroup, which fills it, and buildChatDisplayList, which is
256
+ * the only reader. */
257
+ getStoppedIndexIds(): { [serverItemId: string]: boolean } {
258
+ return this.state.stoppedIndexIds;
259
+ }
260
+
261
+ /**
262
+ * Is this file ALREADY being indexed by this client?
263
+ *
264
+ * One live run per file, and the reason is what a second one looks like: the
265
+ * conversation grows a SECOND collapsed row for the same file (a run is opened
266
+ * by every FIRST pass, so two of them are two rows), the same document is read
267
+ * twice at full provider cost, and the two chains fight over the same records —
268
+ * the delete-then-repost that starts run 2 wipes what run 1 has saved so far.
269
+ *
270
+ * Asked of this client's own live work, so it cannot be wrong in the dangerous
271
+ * direction: a queued/running pass keeps its bgTaskQueue entry until its bubble
272
+ * settles, and a settled run answers false, which is what a genuine later
273
+ * re-index needs.
274
+ *
275
+ * The retry that made this necessary: a chip whose INDEX request failed is
276
+ * handed back to the composer to be retried on the next send, and an index
277
+ * request can fail from the client's side (a lost ack, an expired token on the
278
+ * response) while the server has already queued the pass. The retry then indexes
279
+ * a file that was never not being indexed.
280
+ */
281
+ hasLiveIndexRun(storagePath?: string): boolean {
282
+ if (!storagePath) return false;
283
+ // A dispatch this client has STARTED but not yet heard back about. Nothing
284
+ // else can see one: a bgTaskQueue entry exists only once the ack lands, the
285
+ // bubble only once the drain runs after that, and the server's own status
286
+ // index is eventually consistent — the adopt ladder retries at 0/2s/6s
287
+ // precisely because a row created seconds ago can be missing from it. So
288
+ // between deciding to index a file and the queue admitting it, EVERY source
289
+ // of truth says "not indexing", and a second dispatch in that window is
290
+ // exactly the duplicate this guard exists to stop. Observed live: two first
291
+ // passes four seconds apart, two collapsed rows.
292
+ var claimed = this._indexClaims[this._indexClaimKey(storagePath)];
293
+ if (claimed && nowMs() - claimed < INDEX_DISPATCH_CLAIM_MS) return true;
294
+ var id = this.host.getIdentity();
295
+ for (var i = 0; i < this.bgTaskQueue.length; i++) {
296
+ var e = this.bgTaskQueue[i];
297
+ if (e && e.storagePath === storagePath && e.projectId === id.projectId && e.platform === id.platform) return true;
298
+ }
299
+ return this.state.messages.some(function (m) {
300
+ if (!m.isBackgroundTask || m.role !== 'user' || m.isCancelled) return false;
301
+ if (!(m.isPendingQueued || m.isPendingInProcess || m.isSendingToServer)) return false;
302
+ return !!m._indexFile && m._indexFile.path === storagePath;
303
+ });
304
+ }
305
+
306
+ /** Storage paths are project-relative, and one ChatSession serves every
307
+ * project, so a claim has to be scoped the way a stop is (_indexKeyOf). */
308
+ private _indexClaimKey(storagePath: string): string {
309
+ return this.getHistoryCacheKey() + '|' + storagePath;
310
+ }
311
+
312
+ /**
313
+ * Take this file's indexing slot, or report that someone already has it.
314
+ *
315
+ * The check-and-CLAIM is what makes it safe against a second caller arriving
316
+ * mid-flight: the claim is written SYNCHRONOUSLY, before the first await, so a
317
+ * concurrent caller sees it even though no request has completed and no queue
318
+ * has admitted anything. Ask-then-dispatch could not do that — every source it
319
+ * consults only learns about a dispatch after the ack.
320
+ *
321
+ * Returns true when the caller owns the slot and should dispatch. A caller that
322
+ * then fails to dispatch MUST releaseIndexRun, or the file waits out the claim
323
+ * (a few minutes) before it can be retried.
324
+ */
325
+ claimIndexRun(storagePath?: string): Promise<boolean> {
326
+ var self = this;
327
+ if (!storagePath) return Promise.resolve(true);
328
+ if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(false);
329
+ this._indexClaims[this._indexClaimKey(storagePath)] = nowMs();
330
+ return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS)
331
+ .then(function () {
332
+ if (!self.state.liveIndexKeys[storagePath]) return true;
333
+ self.releaseIndexRun(storagePath);
334
+ return false;
335
+ })
336
+ .catch(function () { return true; });
337
+ }
338
+
339
+ /** Give the slot back — the dispatch failed, or was abandoned. */
340
+ releaseIndexRun(storagePath?: string): void {
341
+ if (storagePath) delete this._indexClaims[this._indexClaimKey(storagePath)];
342
+ }
343
+
344
+ /**
345
+ * The same question, asked of the SERVER when this page cannot answer it.
346
+ *
347
+ * hasLiveIndexRun only knows what this page did. That is not enough for the
348
+ * case duplicates actually come from: the first run was started before a
349
+ * reload, or in another tab, or its bubble has since been paged out of the
350
+ * loaded window — and then the retry finds nothing locally and starts a second
351
+ * run of a file that is still being indexed. The queue is the one place that
352
+ * knows, and it is already asked for exactly this list.
353
+ *
354
+ * Only a POSITIVE answer is used. Absence proves nothing here (the query is
355
+ * capped, and `liveIndexChecked` records that), so an unanswerable question
356
+ * falls back to dispatching — the cost of a wrong "no" is the duplicate this
357
+ * exists to prevent, and the cost of a wrong "yes" is a file that never gets
358
+ * indexed at all. Only one of those is recoverable by the user.
359
+ */
360
+ isIndexRunLive(storagePath?: string): Promise<boolean> {
361
+ var self = this;
362
+ if (!storagePath) return Promise.resolve(false);
363
+ if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(true);
364
+ return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS)
365
+ .then(function () { return !!self.state.liveIndexKeys[storagePath]; })
366
+ .catch(function () { return false; });
367
+ }
368
+
369
+ /** Re-ask the queue which files are still being indexed, unless the answer we
370
+ * have is younger than `maxAgeMs`. Shared by every caller that needs a current
371
+ * one; the display layer's own refresh path is the adopt ladder. */
372
+ private _refreshLiveIndexKeys(maxAgeMs: number): Promise<void> {
373
+ var self = this;
374
+ var id = this.host.getIdentity();
375
+ var platform = id.platform;
376
+ if (!id.projectId || (platform !== 'claude' && platform !== 'openai')) return Promise.resolve();
377
+ // The chat this query is FOR, snapshotted before the round trip. The record
378
+ // below is gated on it still being the chat on screen: the adopt ladder makes
379
+ // the same resolve-time identity check, and without it a project switch inside
380
+ // this query's RTT published chat A's queue answer as chat B's snapshot —
381
+ // where absence reads as a green "Indexed" for files B is indexing right now.
382
+ var askedKey = this.getHistoryCacheKey();
383
+ if (this._liveIndexKey === askedKey && nowMs() - this._liveIndexAt < maxAgeMs) {
384
+ return Promise.resolve();
385
+ }
386
+ var queue = bgIndexingQueueName(id.userId, id.projectId);
387
+ var ask = function (status: 'pending' | 'running') {
388
+ return Promise.resolve(getChatHistory(
389
+ { service: id.projectId, owner: id.owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
390
+ { limit: WORKER_PASS_ADOPT_LIMIT },
391
+ )).catch(function () { return null; });
392
+ };
393
+ return Promise.all([ask('pending'), ask('running')]).then(function (results) {
394
+ if (results[0] === null || results[1] === null) return;
395
+ if (self.getHistoryCacheKey() !== askedKey) return;
396
+ self._liveIndexKey = askedKey;
397
+ self._recordLiveIndexKeys(results);
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Replace the live-index snapshot from a queue query's raw items.
403
+ *
404
+ * Whole-snapshot, never incremental: the query returns everything unresolved on
405
+ * the queue, so a file MISSING from it is precisely the fact we are after. Merging
406
+ * would make a finished file impossible to observe.
407
+ */
408
+ private _recordLiveIndexKeys(lists: any[]): void {
409
+ var next: { [k: string]: boolean } = {};
410
+ // A page that came back FULL is not a whole-queue answer: the query is capped
411
+ // and ordered newest-first, so a bulk upload of 40 files returns the 20 newest
412
+ // and omits the 20 about to run. Absence would then read as "finished" for
413
+ // exactly the files that have not been touched yet. Report not-knowing instead.
414
+ var truncated = false;
415
+ // Locally settled already. The adopt path refuses these items for the same
416
+ // reason and says why: the status index is eventually consistent, so the pass
417
+ // that just finished can still come back as running. Counting it as live work
418
+ // leaves a finished file spinning with nothing left to ever settle and correct
419
+ // it — the two readings of one payload must not disagree.
420
+ var settledIds: { [id: string]: boolean } = {};
421
+ this.state.messages.forEach(function (m) {
422
+ if (!m._serverItemId) return;
423
+ if (m.isPending || m.isPendingInProcess || m.isPendingQueued) return;
424
+ settledIds[m._serverItemId] = true;
425
+ });
426
+ for (var li = 0; li < lists.length; li++) {
427
+ var list = lists[li] && Array.isArray(lists[li].list) ? lists[li].list : [];
428
+ if (list.length >= WORKER_PASS_ADOPT_LIMIT) truncated = true;
429
+ for (var i = 0; i < list.length; i++) {
430
+ var item = list[i];
431
+ if (!item || (item.status !== 'pending' && item.status !== 'running')) continue;
432
+ if (item.id && settledIds[item.id]) continue;
433
+ var text = extractLastUserTextFromRequest(item.request_body);
434
+ if (!isIndexingRequestText(text)) continue; // an ordinary chat on this queue
435
+ var ref = parseIndexingRequestText(text);
436
+ if (!ref) continue;
437
+ // BOTH, not `path || name`. A row's key is normally the storage path,
438
+ // but buildChatDisplayList falls back to the filename when the pass it
439
+ // opened the run with carried no path (an old cached bubble, a label
440
+ // with no link). A key recorded one way and looked up the other misses,
441
+ // and a miss reads as FINISHED — the one direction that must never
442
+ // happen by accident. Recording both can only over-match, which shows a
443
+ // loader on a file that is done: visibly cautious instead of silently
444
+ // wrong.
445
+ if (ref.path) next[ref.path] = true;
446
+ if (ref.name) next[ref.name] = true;
447
+ }
448
+ }
449
+ var nowChecked = !truncated;
450
+ var was = this.state.liveIndexKeys, changed = this.state.liveIndexChecked !== nowChecked;
451
+ if (!changed) {
452
+ for (var k in next) if (!was[k]) { changed = true; break; }
453
+ if (!changed) for (var k2 in was) if (!next[k2]) { changed = true; break; }
454
+ }
455
+ this.state.liveIndexKeys = next;
456
+ this.state.liveIndexChecked = nowChecked;
457
+ this._liveIndexAt = nowMs();
458
+ // Claim the snapshot for the CURRENT chat. Both callers verify identity before
459
+ // recording, so this is always true - but only _refreshLiveIndexKeys used to say
460
+ // so, and the adopt ladder (the path every plain page load actually seeds
461
+ // through) never did. _liveIndexKey then stayed '', and the first-page reset in
462
+ // loadHistory ('loadKey !== _liveIndexKey') fired on EVERY load - including every
463
+ // tab refocus - wiping checked/keys and flipping every settled indexing row back
464
+ // to the grey "checking status" until the queue answered again. With the claim,
465
+ // that reset fires only on a genuine project/platform switch; a refocus keeps
466
+ // showing the last known state (green/yellow) and the re-poll that follows
467
+ // replaces the snapshot atomically when its answer lands.
468
+ this._liveIndexKey = this.getHistoryCacheKey();
469
+ if (changed) this.host.notify();
470
+ }
471
+
472
+ /** Forget the snapshot: it describes ONE chat's queue, and the answer for the
473
+ * project the user just switched to is unknown until it is asked for again. */
474
+ private _resetLiveIndexKeys(): void {
475
+ this.state.liveIndexKeys = {};
476
+ this.state.liveIndexChecked = false;
477
+ this._liveIndexAt = 0;
478
+ }
479
+
480
+ /**
481
+ * Ask the queue what is still indexing, once, for the chat that is on screen.
482
+ *
483
+ * Seeds the snapshot on a history load. Without it a reloaded chat has no way to
484
+ * learn that a run it can see is over: the adopt ladder that normally answers this
485
+ * only fires when a pass SETTLES, and after a reload there is no pass left to
486
+ * settle — so every finished worker-driven row would spin forever.
487
+ *
488
+ * Best-effort: a failure leaves `checked` false, which reads as "still working"
489
+ * rather than as a false all-clear.
490
+ *
491
+ * Delegates to the adopt ladder rather than asking once. A single empty look is
492
+ * exactly what that ladder exists to distrust — the worker writes pass N+1 a few
493
+ * milliseconds AFTER flipping pass N to resolved, so a query landing in that gap
494
+ * sees an empty queue for a chain that is very much alive. One look would turn
495
+ * that into a confident "Indexed" with a green check, on the one scenario this
496
+ * whole feature is for, and nothing would ever re-ask: the ladder is normally
497
+ * triggered by a pass SETTLING, and after a reload there is no pass left to
498
+ * settle. The ladder re-asks at 0/2s/6s, records each answer, and as a bonus
499
+ * adopts and polls any live pass it finds, which makes the row genuinely active
500
+ * instead of merely unconfirmed.
501
+ */
502
+ refreshLiveIndexState(): void {
503
+ this._adoptWorkerIndexingPasses(0);
504
+ }
505
+
506
+ /** Forget what we know about which files are indexing — but ONLY when the
507
+ * snapshot was taken for a different chat than the one on screen now. For a
508
+ * consumer whose history loading is its own fork and so never reaches
509
+ * loadHistory's reset — a snapshot describes ONE chat's queue, and carrying it
510
+ * into another project would let a row there claim to be finished on someone
511
+ * else's evidence.
512
+ *
513
+ * Conditional for the same reason loadHistory's own reset is (the
514
+ * `loadKey !== _liveIndexKey` gate): the view calls this on every mount, and
515
+ * an unconditional wipe turned every re-entry to the chat into a grey
516
+ * "Checking status:" sweep across rows whose state was already known. A
517
+ * RE-entry keeps showing the last answer (green/yellow) while the first-page
518
+ * refresh re-asks quietly; only a genuine project/platform switch starts from
519
+ * "not known yet". Claiming `_liveIndexKey` here (before any answer) is the
520
+ * same fudge loadHistory makes: it marks WHOSE chat the empty snapshot is
521
+ * for, so repeated calls do not re-wipe, and _recordLiveIndexKeys re-claims
522
+ * it when the real answer lands. */
523
+ resetLiveIndexState(): void {
524
+ var key = this.getHistoryCacheKey();
525
+ if (key === this._liveIndexKey) return;
526
+ this._liveIndexKey = key;
527
+ this._resetLiveIndexKeys();
528
+ }
529
+
530
+ /** Wrap an indexing-request dispatch so awaitIndexingDrained counts it as
531
+ * live work from the moment it is sent, not from the moment it is acked. */
532
+ trackIndexDispatch<T>(p: Promise<T>): Promise<T> {
533
+ var self = this;
534
+ this._indexDispatchesInFlight += 1;
535
+ // Deliberately does NOT nudge the drain when the count reaches zero. That
536
+ // moment is a pass being ACCEPTED — new work ENTERING the queue — not work
537
+ // ending. Nudging there pulled a waiting turn's two confirming looks into a
538
+ // fixed ~4.5s window after the ack, which is inside the several seconds a
539
+ // worker-minted pass can take to show up in the status index (the same
540
+ // staleness WORKER_PASS_ADOPT_ATTEMPTS exists for). It turned a rare unlucky
541
+ // alignment into a reliable one: the turn dispatched ahead of its own file's
542
+ // remaining passes and the model answered from a partly-read file. There was
543
+ // nothing to gain either — the pass it signals is work, so the pulled-forward
544
+ // look can only report busy, or lie.
545
+ var release = function () { self._indexDispatchesInFlight = Math.max(0, self._indexDispatchesInFlight - 1); };
546
+ return p.then(function (v) { release(); return v; }, function (e) { release(); throw e; });
547
+ }
548
+
549
+ /**
550
+ * Something just happened that plausibly ENDED indexing work, so let any waiting
551
+ * turn look now instead of sitting out the rest of its busy interval.
552
+ *
553
+ * A nudge changes only WHEN a look happens, never what it concludes: the two
554
+ * agreeing idle looks, the confirm gap between them, "a failed look counts as
555
+ * busy" and the minimum wait are all untouched. That is why it is safe to fire
556
+ * from places that are merely good guesses.
557
+ *
558
+ * Fired from end-of-chain points ONLY: the adopt ladder giving up, a resume
559
+ * declining to continue, a pass failing. Not from every settling pass (one nudge
560
+ * per pass per file for the whole run), and not from an indexing request being
561
+ * accepted — see the note in trackIndexDispatch for why that one is actively
562
+ * harmful rather than merely wasteful.
563
+ */
564
+ private _nudgeIndexingDrain(): void {
565
+ if (!this._drainNudges.length) return;
566
+ var list = this._drainNudges.slice(); // a nudge may de-register itself
567
+ for (var i = 0; i < list.length; i++) {
568
+ try { list[i](); } catch (e) { /* best-effort: never break the caller */ }
569
+ }
136
570
  }
137
571
 
138
572
  /**
@@ -153,6 +587,19 @@ export class ChatSession {
153
587
  return p;
154
588
  }
155
589
 
590
+ /** Background polls currently attached, for the MAX_CONCURRENT_BG_POLLS budget.
591
+ * Counts the registry rather than a separate tally so it cannot drift: every
592
+ * attach goes through _trackPoll and every detach deletes the entry. Note an
593
+ * entry left behind by pausePolling on an older skapi-js (no stop handle)
594
+ * still counts, which is correct — that poll really is still running. */
595
+ private _countBgPolls(): number {
596
+ var n = 0;
597
+ this.historyItemPolls.forEach(function (handle) {
598
+ if (handle && handle.kind === 'bg') n++;
599
+ });
600
+ return n;
601
+ }
602
+
156
603
  /**
157
604
  * Stop and forget one item's poll. Used after a cancel: the row is either gone
158
605
  * (cancelled while queued) or flagged cancelled (cancelled while running), so
@@ -237,8 +684,8 @@ export class ChatSession {
237
684
 
238
685
  getHistoryCacheKey(): string {
239
686
  var id = this.host.getIdentity();
240
- if (!id.serviceId || id.platform === 'none') return '';
241
- return id.serviceId + '#' + id.platform;
687
+ if (!id.projectId || id.platform === 'none') return '';
688
+ return id.projectId + '#' + id.platform;
242
689
  }
243
690
 
244
691
  updateHistoryCache(): void {
@@ -252,6 +699,21 @@ export class ChatSession {
252
699
  // replay on every later visit to B. Bubbles with no _ownerKey (server
253
700
  // history, bg tasks) are always kept. Single pass: this runs on the
254
701
  // typewriter hot path.
702
+ //
703
+ // Staged bubbles (_stageId) ARE cached, deliberately. They used to be dropped
704
+ // on the theory that a reload kills the upload but not the cache, leaving a
705
+ // message that uploads forever — but this cache is an in-memory field on a
706
+ // singleton created at module load, so a reload takes it and the upload chain
707
+ // together and that state is unreachable. What dropping them DID cost was
708
+ // real: state.messages is shared across projects, so both clients filter it by
709
+ // _ownerKey on every chat switch, and with no cached copy the bubble was
710
+ // destroyed outright. A user who sent a question with a file and then looked at
711
+ // another project lost the question for the whole indexing wait and got it back
712
+ // at the bottom minutes later.
713
+ //
714
+ // isLiveStage + settleDeadStagedMessages keep the original concern honest if
715
+ // this cache is ever persisted: a restored stage whose upload no longer exists
716
+ // settles into a plain message instead of uploading forever.
255
717
  this.aiChatHistoryCache[key] = {
256
718
  messages: this.state.messages.filter(function (m) {
257
719
  return m._ownerKey === undefined || m._ownerKey === key;
@@ -307,6 +769,11 @@ export class ChatSession {
307
769
  for (var j = 0; j < msgs.length; j++) {
308
770
  var u = msgs[j];
309
771
  if (!u || u.role !== 'user' || u.isBackgroundTask) continue;
772
+ // A STAGED bubble matches this shape exactly — pending, no server id, so the
773
+ // id test below cannot rule it out — and sits EARLIER in the list than the
774
+ // turn actually resolving. Settling it would strip its _stageId and its
775
+ // upload state, silently killing a turn whose files are still going up.
776
+ if (u._stageId) continue;
310
777
  if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
311
778
  if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
312
779
  var settled: ChatMessage = { role: 'user', content: u.content };
@@ -324,21 +791,21 @@ export class ChatSession {
324
791
  }
325
792
 
326
793
  /**
327
- * serviceId/owner are passed explicitly by every caller: a request can be
794
+ * projectId/owner are passed explicitly by every caller: a request can be
328
795
  * dispatched after the user moved to another project, and re-reading the live
329
796
  * identity here would silently send the turn to THAT project instead of the
330
797
  * one it was composed for. Falls back to the live read only when a caller
331
798
  * omits them.
332
799
  */
333
- private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any, serviceId?: string, owner?: string) {
334
- if (serviceId === undefined || owner === undefined) {
800
+ private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any, projectId?: string, owner?: string) {
801
+ if (projectId === undefined || owner === undefined) {
335
802
  var id = this.host.getIdentity();
336
- if (serviceId === undefined) serviceId = id.serviceId;
803
+ if (projectId === undefined) projectId = id.projectId;
337
804
  if (owner === undefined) owner = id.owner;
338
805
  }
339
806
  return platform === 'openai'
340
- ? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls)
341
- : callClaudeWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls);
807
+ ? callOpenAIWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls)
808
+ : callClaudeWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls);
342
809
  }
343
810
 
344
811
  dispatchAgentRequest(params: any) {
@@ -352,7 +819,7 @@ export class ChatSession {
352
819
  var dispatchItemId: string | undefined;
353
820
  var sendAndPoll = function () {
354
821
  return Promise.resolve(
355
- self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.serviceId, params.owner)
822
+ self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.projectId, params.owner)
356
823
  ).then(function (initial: any) {
357
824
  if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
358
825
  if (initial.id) {
@@ -432,12 +899,294 @@ export class ChatSession {
432
899
  return run;
433
900
  }
434
901
 
902
+ /**
903
+ * Put a turn on screen the INSTANT the user hits Send, before its attachments
904
+ * have finished uploading. Uploads run in the background now (the composer is
905
+ * cleared and stays usable), so without a staged bubble the message would
906
+ * appear only once its files were up — below anything the user sent in the
907
+ * meantime, in an order that never matches what they typed.
908
+ *
909
+ * Staged bubbles carry _useBgQueue because that is where a turn with
910
+ * attachments ultimately dispatches (behind its own indexing tasks). That flag
911
+ * is also what keeps promoteNextQueuedToRunning / resolveQueuedUserBubble off
912
+ * them: those advance the SERVER queue, and a staged turn has no server
913
+ * request behind it yet.
914
+ *
915
+ * Returns the id to hand back as PinnedDispatchContext.stageId at dispatch.
916
+ */
917
+ stageOutgoingMessage(displayText: string): string {
918
+ this._stageSeq += 1;
919
+ var stageId = 'stg_' + this._stageSeq;
920
+ var key = this.getHistoryCacheKey();
921
+ var staged: ChatMessage = {
922
+ role: 'user', content: displayText,
923
+ isPendingQueued: true, isUploadingAttachments: true, isSendingToServer: true,
924
+ _dimSending: true,
925
+ // A staged bubble has no server id for minutes, and its indexing rows are
926
+ // now inserted ABOVE it — so its array index moves. Both views fall back to
927
+ // the index when a bubble has no id, which would re-key (and in Vue, remount)
928
+ // this bubble on every file, restarting its transition and losing it as a
929
+ // scroll anchor. A local id it keeps for its whole life fixes both.
930
+ _localId: this._newLocalId(),
931
+ _useBgQueue: true, _stageId: stageId, _ts: wallClockNow(),
932
+ };
933
+ if (key) staged._ownerKey = key;
934
+ this._liveStages[stageId] = true;
935
+ this.state.messages.push(staged);
936
+ this.host.notify(); this.host.scrollToBottom(true);
937
+ return stageId;
938
+ }
939
+
940
+ /** Is anything in this page still uploading/dispatching for this stage? */
941
+ isLiveStage(stageId?: string): boolean {
942
+ return !!stageId && !!this._liveStages[stageId];
943
+ }
944
+
945
+ /**
946
+ * Settle any staged bubble in `list` whose chain no longer exists, and return the
947
+ * list (a new array only if something changed).
948
+ *
949
+ * The caller is a cache restore. A staged bubble is the one kind of message whose
950
+ * resolution lives entirely in page memory — no server request stands behind it
951
+ * yet — so a copy that outlives its upload would render "(Uploading files...)"
952
+ * forever with nothing left to finish it. Today nothing can: this cache dies with
953
+ * the page, so every restored stage is still live and this is a no-op. It exists
954
+ * so that stops being a silent assumption.
955
+ */
956
+ settleDeadStagedMessages(list: ChatMessage[]): ChatMessage[] {
957
+ if (!Array.isArray(list)) return list;
958
+ var self = this;
959
+ var dead = false;
960
+ for (var i = 0; i < list.length; i++) {
961
+ var m = list[i];
962
+ if (m && m._stageId && !self._liveStages[m._stageId]) { dead = true; break; }
963
+ }
964
+ if (!dead) return list;
965
+ return list.map(function (m) {
966
+ if (!m || !m._stageId || self._liveStages[m._stageId]) return m;
967
+ var settled: ChatMessage = { role: 'user', content: m.content };
968
+ if (m._ownerKey !== undefined) settled._ownerKey = m._ownerKey;
969
+ if (m._ts !== undefined) settled._ts = m._ts;
970
+ if (m._localId !== undefined) settled._localId = m._localId;
971
+ return settled;
972
+ });
973
+ }
974
+
975
+ private _stageIndex(list: ChatMessage[], stageId?: string): number {
976
+ if (!stageId) return -1;
977
+ for (var i = 0; i < list.length; i++) {
978
+ if (list[i] && list[i]._stageId === stageId) return i;
979
+ }
980
+ return -1;
981
+ }
982
+
983
+ /**
984
+ * Staged turn, phase 2: its files are up and it is now waiting for the whole
985
+ * indexing chain behind them. Swaps "(Uploading files...)" for
986
+ * "(Indexing files...)"; the bubble stays dimmed, because from the user's side
987
+ * nothing has been handed over yet.
988
+ *
989
+ * It deliberately does NOT say "(In queue)" here. The turn is not queued behind
990
+ * anything the server knows about yet — it is waiting on work that can run for
991
+ * minutes — and claiming otherwise is what made the wait look like a stall.
992
+ */
993
+ markStagedMessageIndexing(stageId: string): void {
994
+ var idx = this._stageIndex(this.state.messages, stageId);
995
+ if (idx === -1) return;
996
+ var ex = this.state.messages[idx];
997
+ if (!ex.isUploadingAttachments) return;
998
+ this.state.messages[idx] = Object.assign({}, ex, {
999
+ isUploadingAttachments: false, isAwaitingIndexing: true,
1000
+ });
1001
+ this.host.notify();
1002
+ }
1003
+
1004
+ /**
1005
+ * Staged turn, phase 3: the last of its files has finished indexing, so the turn
1006
+ * is genuinely just queued now. Full opacity + "(In queue)".
1007
+ *
1008
+ * Clears the PRESENTATIONAL _dimSending only; isSendingToServer stays set until
1009
+ * the server actually acks (it is the token that ack matches on). Called by the
1010
+ * clients the instant awaitIndexingDrained resolves, i.e. immediately before the
1011
+ * dispatch that replaces this bubble — dispatchComposedMessage carries the
1012
+ * cleared flag onto the replacement so the turn does not blink back to dimmed.
1013
+ */
1014
+ markStagedMessageReady(stageId: string): void {
1015
+ var idx = this._stageIndex(this.state.messages, stageId);
1016
+ if (idx === -1) return;
1017
+ var ex = this.state.messages[idx];
1018
+ if (!ex.isAwaitingIndexing && !ex._dimSending && !ex.isUploadingAttachments) return;
1019
+ this.state.messages[idx] = Object.assign({}, ex, {
1020
+ isUploadingAttachments: false, isAwaitingIndexing: false, _dimSending: false,
1021
+ });
1022
+ this.host.notify();
1023
+ }
1024
+
1025
+ /**
1026
+ * Resolves once this project's background-indexing queue has nothing left to
1027
+ * run, so a chat enqueued right after it is genuinely last.
1028
+ *
1029
+ * Sending the chat as soon as the uploads finish is not enough, which is the
1030
+ * whole reason this exists: indexing a file is a CHAIN, and each pass is only
1031
+ * enqueued once the previous one lands (the client mints CONTINUE passes for
1032
+ * text/grid files, the worker mints them for PDFs and windowed reads). Every
1033
+ * one of those passes therefore queues up BEHIND a chat sent at upload time,
1034
+ * and the model answers from a file it has only partly read.
1035
+ *
1036
+ * The queue is read from the server's status index rather than from
1037
+ * bgTaskQueue: that mirror holds only what this client dispatched or adopted,
1038
+ * and it stops being maintained once the view unmounts. An empty answer has to
1039
+ * repeat before it is believed — see INDEXING_DRAIN_IDLE_LOOKS — and a look
1040
+ * that fails counts as busy, so a dropped request delays the turn instead of
1041
+ * releasing it early.
1042
+ *
1043
+ * Reads the identity PINNED at Send time, never a live one: the user may be in
1044
+ * another project by now, and this must keep asking about the one they sent
1045
+ * from.
1046
+ */
1047
+ awaitIndexingDrained(identity: ChatIdentity): Promise<'drained' | 'timedout' | 'skipped'> {
1048
+ var self = this;
1049
+ var svcId = identity && identity.projectId;
1050
+ var platform = identity && identity.platform;
1051
+ if (!svcId || (platform !== 'claude' && platform !== 'openai')) return Promise.resolve('skipped' as const);
1052
+ var owner = identity.owner;
1053
+ var queue = bgIndexingQueueName(identity.userId, svcId);
1054
+ var startedAt = nowMs();
1055
+ var deadline = startedAt + INDEXING_DRAIN_TIMEOUT_MS;
1056
+ var idleLooks = 0;
1057
+ // null = "could not find out", which the loop below counts as busy. The race
1058
+ // is what makes the timeout necessary rather than tidy: a getChatHistory that
1059
+ // never settles leaves no pending timer behind, so the whole wait — and the
1060
+ // turn, and its bubble — would hang on it indefinitely.
1061
+ var ask = function (status: 'pending' | 'running') {
1062
+ var answered = false;
1063
+ return new Promise<any>(function (res) {
1064
+ var bail: any = null;
1065
+ var settle = function (v: any) {
1066
+ if (answered) return;
1067
+ answered = true;
1068
+ if (bail) { clearTimeout(bail); bail = null; }
1069
+ res(v);
1070
+ };
1071
+ bail = setTimeout(function () { settle(null); }, INDEXING_DRAIN_LOOK_TIMEOUT_MS);
1072
+ Promise.resolve(getChatHistory(
1073
+ { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
1074
+ { limit: WORKER_PASS_ADOPT_LIMIT },
1075
+ )).then(function (r: any) { settle(r); }, function () { settle(null); });
1076
+ });
1077
+ };
1078
+ // Ordinary chats are routed onto this queue too (_isOnBgQueue), and those
1079
+ // are not work this turn has to wait behind — the server runs the queue in
1080
+ // order, so being enqueued after them is enough.
1081
+ var hasLiveIndexing = function (res: any): boolean {
1082
+ var list = res && Array.isArray(res.list) ? res.list : [];
1083
+ for (var i = 0; i < list.length; i++) {
1084
+ var item = list[i];
1085
+ if (!item || (item.status !== 'pending' && item.status !== 'running')) continue;
1086
+ if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) return true;
1087
+ }
1088
+ return false;
1089
+ };
1090
+ return new Promise(function (resolve) {
1091
+ var timer: any = null;
1092
+ var lastLookAt = -Infinity;
1093
+ var nudgedThisInterval = false;
1094
+ // A look's queries are OUT. Nothing may start a second one alongside it:
1095
+ // two overlapping looks share idleLooks, so both coming back idle would
1096
+ // count as the two agreeing looks WITHOUT the confirm gap between them —
1097
+ // the one guard that stands between the user and an answer computed from a
1098
+ // half-indexed file.
1099
+ var inFlight = false;
1100
+ var finish = function (v: 'drained' | 'timedout') {
1101
+ if (timer) { clearTimeout(timer); timer = null; }
1102
+ // De-register on BOTH exits or the waiter leaks a closure per send, and
1103
+ // a later settle re-arms a timer for a turn that has already gone out.
1104
+ var ni = self._drainNudges.indexOf(nudge);
1105
+ if (ni !== -1) self._drainNudges.splice(ni, 1);
1106
+ resolve(v);
1107
+ };
1108
+ var again = function (ms?: number) {
1109
+ if (timer) { clearTimeout(timer); timer = null; }
1110
+ var wait = ms == null ? (idleLooks > 0 ? INDEXING_DRAIN_CONFIRM_POLL_MS : INDEXING_DRAIN_BUSY_POLL_MS) : ms;
1111
+ timer = setTimeout(look, wait);
1112
+ };
1113
+ // Indexing just ended somewhere (see _nudgeIndexingDrain): ask now rather
1114
+ // than waiting out the rest of the busy interval, which is what made the
1115
+ // turn sit dimmed for another ten seconds after its files were visibly done.
1116
+ var nudge = function () {
1117
+ // Already on the confirm cadence — the two agreeing looks and the gap
1118
+ // between them are the guard, and pulling the second one forward is
1119
+ // exactly what must not happen.
1120
+ if (idleLooks > 0) return;
1121
+ if (inFlight) return; // a look is already asking; nothing to pull forward
1122
+ if (nudgedThisInterval) return; // one pull-forward per interval
1123
+ // A look now would short-circuit on this anyway. Nothing re-fires when
1124
+ // the count reaches zero, deliberately (see trackIndexDispatch): the
1125
+ // cost is up to one busy interval of extra dim, and the alternative is
1126
+ // a nudge fired by work STARTING, which can release a turn early.
1127
+ if (self._indexDispatchesInFlight > 0) return;
1128
+ nudgedThisInterval = true;
1129
+ again(Math.max(0, INDEXING_DRAIN_NUDGE_MIN_GAP_MS - (nowMs() - lastLookAt)));
1130
+ };
1131
+ var look = function () {
1132
+ timer = null;
1133
+ // Belt to the nudge's brace: never two sets of queries out at once.
1134
+ if (inFlight) return;
1135
+ lastLookAt = nowMs(); nudgedThisInterval = false;
1136
+ if (nowMs() >= deadline) { finish('timedout'); return; }
1137
+ // A pass whose ack is still in flight is not on the queue yet, so no
1138
+ // look can see it.
1139
+ if (self._indexDispatchesInFlight > 0) { idleLooks = 0; again(); return; }
1140
+ inFlight = true;
1141
+ Promise.all([ask('running'), ask('pending')]).then(function (res: any[]) {
1142
+ inFlight = false;
1143
+ var unknown = res[0] === null || res[1] === null;
1144
+ if (unknown || hasLiveIndexing(res[0]) || hasLiveIndexing(res[1])) idleLooks = 0;
1145
+ else idleLooks += 1;
1146
+ if (idleLooks >= INDEXING_DRAIN_IDLE_LOOKS && nowMs() - startedAt >= INDEXING_DRAIN_MIN_MS) {
1147
+ finish('drained'); return;
1148
+ }
1149
+ again();
1150
+ }, function () { inFlight = false; idleLooks = 0; again(); });
1151
+ };
1152
+ self._drainNudges.push(nudge);
1153
+ look();
1154
+ });
1155
+ }
1156
+
1157
+ /**
1158
+ * Abandon a staged turn — its uploads failed outright, so nothing will be
1159
+ * dispatched. The bubble stays (the user's text is not silently thrown away)
1160
+ * but settles into a plain, non-pending message; the caller reports the
1161
+ * failure separately.
1162
+ */
1163
+ settleStagedMessage(stageId: string): void {
1164
+ // Retired even when the bubble is not on screen (another project is): the
1165
+ // chain is over either way, and a cached copy must not read as still working.
1166
+ delete this._liveStages[stageId];
1167
+ var idx = this._stageIndex(this.state.messages, stageId);
1168
+ if (idx === -1) return;
1169
+ var ex = this.state.messages[idx];
1170
+ var settled: ChatMessage = { role: 'user', content: ex.content };
1171
+ if (ex._ownerKey !== undefined) settled._ownerKey = ex._ownerKey;
1172
+ if (ex._ts !== undefined) settled._ts = ex._ts;
1173
+ // Same key across the settle: it has no server id and never will.
1174
+ if (ex._localId !== undefined) settled._localId = ex._localId;
1175
+ this.state.messages[idx] = settled;
1176
+ this.host.notify(); this.updateHistoryCache();
1177
+ }
1178
+
435
1179
  // composed = clean display text; composedForLlm carries office-extraction
436
1180
  // placeholders for the provider only. useBgQueue routes a post-attachment turn
437
1181
  // onto the "-bg" queue so it runs after indexing.
438
1182
  dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any, pinned?: PinnedDispatchContext): void {
439
1183
  var self = this;
440
- if (!composed) return;
1184
+ // This turn may already have a bubble on screen, staged at Send time while
1185
+ // its attachments uploaded. Every exit from here has to account for it:
1186
+ // dispatching replaces it in place (so it keeps the position the user sent
1187
+ // it in), and bailing settles it (so it never sits uploading forever).
1188
+ var stageId = pinned ? pinned.stageId : undefined;
1189
+ if (!composed) { if (stageId) this.settleStagedMessage(stageId); return; }
441
1190
  // A send can be dispatched LONG after the user hit Send (attachment
442
1191
  // uploads are awaited first), by which time the live identity may have
443
1192
  // moved to another project. The caller pins the identity + system prompt
@@ -445,7 +1194,10 @@ export class ChatSession {
445
1194
  // question was actually asked of. Falls back to the live read when the
446
1195
  // caller doesn't pin (the widget, which has only one project anyway).
447
1196
  var id = pinned ? pinned.identity : this.host.getIdentity();
448
- if (id.platform === 'none') return;
1197
+ if (id.platform === 'none') { if (stageId) this.settleStagedMessage(stageId); return; }
1198
+ // Past every bail: this turn is going out, so its stage is over whichever
1199
+ // branch below takes it, and whether or not the bubble is still on screen.
1200
+ if (stageId) delete this._liveStages[stageId];
449
1201
 
450
1202
  var llmComposed = composedForLlm || composed;
451
1203
 
@@ -453,8 +1205,8 @@ export class ChatSession {
453
1205
  // bubble is stamped with it so a project switch (which flips
454
1206
  // getIdentity()/getHistoryCacheKey() to the new project) can't
455
1207
  // misattribute this turn's bubbles to that project.
456
- // (platform === 'none' already returned above, so serviceId is the only gate)
457
- var key = !id.serviceId ? '' : id.serviceId + '#' + id.platform;
1208
+ // (platform === 'none' already returned above, so projectId is the only gate)
1209
+ var key = !id.projectId ? '' : id.projectId + '#' + id.platform;
458
1210
  // True when the pinned chat is NOT the one currently on screen. Then
459
1211
  // state.messages belongs to a different project and MUST NOT be touched:
460
1212
  // the turn is staged in the pinned chat's cache instead and shows up when
@@ -468,8 +1220,14 @@ export class ChatSession {
468
1220
  var aiPlatform = id.platform;
469
1221
  var aiModel = id.model || undefined;
470
1222
  var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
471
- var userId = id.userId || id.serviceId;
472
- var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
1223
+ var userId = id.userId || id.projectId;
1224
+ // Same string the indexing passes are enqueued under (bgIndexingQueueName),
1225
+ // which is the whole reason this turn ends up behind them: the backend runs
1226
+ // different queue names in parallel and only serialises a shared one.
1227
+ var chatQueue = useBgQueue ? bgIndexingQueueName(userId) : userId;
1228
+ // _stageIndex is -1 when nothing was staged, or when the staged bubble is
1229
+ // gone (a remount rebuilds the list from the cache, which never holds staged
1230
+ // bubbles); either way the branches below fall back to appending.
473
1231
 
474
1232
  if (offChat) {
475
1233
  // Stage the turn in the pinned chat's cache and dispatch. The
@@ -482,20 +1240,46 @@ export class ChatSession {
482
1240
  !m.isCancelled && !m.isBackgroundTask && !m.isError;
483
1241
  });
484
1242
  var offBounded = buildBoundedChatMessages({
485
- platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
1243
+ platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
486
1244
  history: offHistory.concat([{ role: 'user', content: llmComposed }]),
487
1245
  });
488
1246
  var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
1247
+ var offUser: ChatMessage = { role: 'user', content: composed, _ownerKey: key, _ts: wallClockNow() };
1248
+ // The user hit Send here, then navigated away before the uploads
1249
+ // finished. The staged bubble belongs to THAT chat, not the one now on
1250
+ // screen, and the turn it stood in for is being cached below — so drop
1251
+ // it from the live list instead of leaving a bubble that uploads
1252
+ // forever in a project it does not belong to.
1253
+ var offStage = this._stageIndex(this.state.messages, stageId);
1254
+ if (offStage !== -1) {
1255
+ if (this.state.messages[offStage]._ts !== undefined) offUser._ts = this.state.messages[offStage]._ts;
1256
+ this.state.messages.splice(offStage, 1);
1257
+ this.host.notify();
1258
+ }
1259
+ // The cached copy of that same staged bubble has to go with it. offUser is
1260
+ // the turn it stood in for, appended just below; leaving both would show the
1261
+ // question twice on the next visit, permanently — the cache is restored
1262
+ // verbatim. (Its _ts is recovered above only when the live bubble is still
1263
+ // there; take it from the cached copy otherwise, so the turn keeps the time
1264
+ // the user sent it at rather than the upload's finish time.)
1265
+ var offCached = offExisting.messages;
1266
+ if (stageId) {
1267
+ offCached = offCached.filter(function (m) {
1268
+ if (m._stageId !== stageId) return true;
1269
+ if (offStage === -1 && m._ts !== undefined) offUser._ts = m._ts;
1270
+ return false;
1271
+ });
1272
+ }
489
1273
  this.aiChatHistoryCache[key] = {
490
- messages: offExisting.messages.concat([
491
- { role: 'user', content: composed, _ownerKey: key, _ts: wallClockNow() },
1274
+ messages: offCached.concat([
1275
+ offUser,
492
1276
  { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
493
1277
  ]),
494
1278
  endOfList: offExisting.endOfList,
495
1279
  startKeyHistory: offExisting.startKeyHistory,
496
1280
  };
497
1281
  this.dispatchAgentRequest({
498
- key: key, serviceId: id.serviceId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
1282
+ key: key, projectId: id.projectId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
499
1283
  systemPrompt: systemPrompt, text: composed, boundedMessages: offBounded.messages, userId: chatQueue,
500
1284
  extractContent: extractContent, fileUrls: fileUrls,
501
1285
  });
@@ -508,28 +1292,53 @@ export class ChatSession {
508
1292
  !m.isCancelled && !m.isBackgroundTask && !m.isError;
509
1293
  });
510
1294
  var boundedQ = buildBoundedChatMessages({
511
- platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
1295
+ platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
512
1296
  history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
513
1297
  });
514
- var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
1298
+ // _localId for the same reason as immediateUser below: a queued turn can be
1299
+ // renumbered by indexing rows spliced in above it, and an id-less bubble is
1300
+ // keyed by index. Overwritten by the staged bubble's own id when replacing one.
1301
+ var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true, _dimSending: true, _localId: this._newLocalId(), _ts: wallClockNow() };
515
1302
  if (key) queuedBubble._ownerKey = key;
516
1303
  if (useBgQueue) queuedBubble._useBgQueue = true;
517
- this.state.messages.push(queuedBubble);
1304
+ var qStage = this._stageIndex(this.state.messages, stageId);
1305
+ if (qStage !== -1) {
1306
+ // Replace IN PLACE. The turn already sits below its own indexing rows —
1307
+ // drainBgTaskQueue inserted each of them directly above this bubble as
1308
+ // the file's pass started — so there is nothing left to reorder, and the
1309
+ // row order the reader has been looking at all along is the same one the
1310
+ // server will report on the next load.
1311
+ var qEx = this.state.messages[qStage];
1312
+ // Keep the send time the user saw, not the upload's finish time.
1313
+ if (qEx._ts !== undefined) queuedBubble._ts = qEx._ts;
1314
+ // This turn waited out its uploads AND its whole indexing chain in full
1315
+ // view; markStagedMessageReady un-dimmed it when that finished. Re-dimming
1316
+ // it now for the one remaining request would read as the wait restarting.
1317
+ if (qEx._dimSending === false) queuedBubble._dimSending = false;
1318
+ if (qEx._localId) queuedBubble._localId = qEx._localId;
1319
+ this.state.messages.splice(qStage, 1, queuedBubble);
1320
+ } else {
1321
+ this.state.messages.push(queuedBubble);
1322
+ }
518
1323
  this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
519
1324
 
520
1325
  var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
521
- Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner))
1326
+ Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.projectId, id.owner))
522
1327
  .then(function (result: any) {
523
1328
  // Only ack a bubble that belongs to THIS chat — the search is
524
1329
  // positional, so on another project it would stamp this turn's
525
1330
  // _serverItemId onto that project's unrelated in-flight bubble.
1331
+ // Staged bubbles (_stageId) are skipped for the same reason: they
1332
+ // sit EARLIER in the list and match this shape exactly, so a turn
1333
+ // whose files are still uploading would take the ack — and the
1334
+ // server id — belonging to the turn that actually just sent.
526
1335
  var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function (m) {
527
1336
  return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user' &&
528
- (m._ownerKey === undefined || m._ownerKey === capturedKey);
1337
+ !m._stageId && (m._ownerKey === undefined || m._ownerKey === capturedKey);
529
1338
  });
530
1339
  var serverId = result && typeof result.id === 'string' ? result.id : undefined;
531
1340
  if (sendingIdx >= 0) {
532
- var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false });
1341
+ var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false, _dimSending: false });
533
1342
  if (serverId) upd._serverItemId = serverId;
534
1343
  self.state.messages[sendingIdx] = upd; self.host.notify();
535
1344
  }
@@ -553,8 +1362,26 @@ export class ChatSession {
553
1362
  // view unmount), then rendered from the cache via typewriteLatestReply. A
554
1363
  // later resumePendingRequest() re-renders it if the view remounted while the
555
1364
  // request was still in flight.
556
- this.state.messages.push({ role: 'user', content: composed, _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) });
557
- this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
1365
+ // _localId on the user bubble too, not just the staged one it may replace: an
1366
+ // ordinary turn sent while another is staged sits BELOW that turn, so every
1367
+ // indexing row spliced in above renumbers it — and a view that keys an id-less
1368
+ // bubble by index would tear it down and rebuild it on every uploaded file.
1369
+ var immediateUser: ChatMessage = { role: 'user', content: composed, _localId: this._newLocalId(), _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) };
1370
+ var immediatePlaceholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) };
1371
+ var iStage = this._stageIndex(this.state.messages, stageId);
1372
+ if (iStage !== -1) {
1373
+ // Replace IN PLACE — see the queued branch above for why nothing moves.
1374
+ var iEx = this.state.messages[iStage];
1375
+ // Keep the send time the user saw, not the upload's finish time.
1376
+ if (iEx._ts !== undefined) immediateUser._ts = iEx._ts;
1377
+ // Same id across the swap, so the row is not re-keyed (and in Vue, remounted)
1378
+ // at the very moment the turn goes out.
1379
+ if (iEx._localId) immediateUser._localId = iEx._localId;
1380
+ this.state.messages.splice(iStage, 1, immediateUser, immediatePlaceholder);
1381
+ } else {
1382
+ this.state.messages.push(immediateUser);
1383
+ this.state.messages.push(immediatePlaceholder);
1384
+ }
558
1385
  this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
559
1386
 
560
1387
  // Same filter as the offChat and isQueuedSend paths above. It must drop the
@@ -576,24 +1403,24 @@ export class ChatSession {
576
1403
  // flag is stripped when buildBoundedChatMessages maps down to {role,content}.
577
1404
  // The cost is that a retry now follows an unanswered question with no stated
578
1405
  // reason, which is a true account of what happened rather than a false one.
1406
+ //
1407
+ // The turn being sent is appended LAST rather than left wherever its bubble
1408
+ // sits. A staged turn keeps the position it was sent in, which is NOT the
1409
+ // end of the list once a message sent after it (while its files uploaded)
1410
+ // has already been answered — and a history that ends on an assistant turn
1411
+ // hits exactly the `last.role !== 'user'` bail described above.
579
1412
  var historyForLlm = this.state.messages.filter(function (m) {
1413
+ if (m === immediateUser) return false;
580
1414
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
581
1415
  !m.isCancelled && !m.isBackgroundTask && !m.isError;
582
1416
  });
583
- if (llmComposed !== composed) {
584
- for (var li = historyForLlm.length - 1; li >= 0; li--) {
585
- if (historyForLlm[li].role === 'user' && historyForLlm[li].content === composed) {
586
- historyForLlm[li] = Object.assign({}, historyForLlm[li], { content: llmComposed });
587
- break;
588
- }
589
- }
590
- }
1417
+ historyForLlm.push({ role: 'user', content: llmComposed });
591
1418
  var bounded = buildBoundedChatMessages({
592
- platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
1419
+ platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
593
1420
  history: historyForLlm,
594
1421
  });
595
1422
  var run = this.dispatchAgentRequest({
596
- key: key, serviceId: id.serviceId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
1423
+ key: key, projectId: id.projectId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
597
1424
  systemPrompt: systemPrompt, text: composed, boundedMessages: bounded.messages, userId: chatQueue,
598
1425
  extractContent: extractContent, fileUrls: fileUrls,
599
1426
  });
@@ -656,6 +1483,14 @@ export class ChatSession {
656
1483
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
657
1484
  if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
658
1485
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
1486
+ // Carried alongside isSendingToServer, never derived from it: an attachment
1487
+ // turn promoted to running while its ack is still out was already un-dimmed
1488
+ // by markStagedMessageReady and must stay that way.
1489
+ if (existing._dimSending) promoted._dimSending = true;
1490
+ // A turn promoted BEFORE its ack has no server id, so this is the only stable
1491
+ // render key it has; dropping it here re-keyed the bubble by index and undid
1492
+ // the reason it was minted (rows spliced in above renumber it).
1493
+ if (existing._localId !== undefined) promoted._localId = existing._localId;
659
1494
  this.state.messages[nextIdx] = promoted;
660
1495
  // Carry the promoted turn's _serverItemId onto the "Thinking..." placeholder
661
1496
  // (mirrors promoteNextBgQueuedToRunning). Without it, when this promoted turn
@@ -672,6 +1507,36 @@ export class ChatSession {
672
1507
  this.host.notify();
673
1508
  }
674
1509
 
1510
+ /**
1511
+ * The "Thinking..." placeholder belonging to the user bubble at `userIdx`, or -1.
1512
+ *
1513
+ * Every path that creates one puts it IMMEDIATELY after its user bubble
1514
+ * (promoteNextQueuedToRunning, the immediate-send pair, applyHistoryItemResolution),
1515
+ * so ownership is adjacency — modulo background bubbles, which get spliced in
1516
+ * around them. Taking the first pending assistant ANYWHERE below instead was a
1517
+ * hijack: a turn sent with attachments never gets a placeholder of its own
1518
+ * (promoteNextQueuedToRunning skips _useBgQueue turns) and now keeps the position
1519
+ * it was sent in, so an ordinary turn sent while its files indexed sits BELOW it
1520
+ * with a placeholder of its own — and the attachment turn's answer was rendered
1521
+ * as the answer to that unrelated question.
1522
+ */
1523
+ private _ownThinkingIndex(userIdx: number, serverId?: string): number {
1524
+ if (userIdx < 0) return -1;
1525
+ for (var i = userIdx + 1; i < this.state.messages.length; i++) {
1526
+ var m = this.state.messages[i];
1527
+ if (!m) return -1;
1528
+ if (m.isBackgroundTask) continue; // an indexing row spliced between the pair
1529
+ if (m.isPending && m.role === 'assistant') {
1530
+ // A placeholder stamped for a DIFFERENT request is not this turn's, even
1531
+ // adjacent: ids are authoritative wherever both sides have one.
1532
+ if (serverId && m._serverItemId && m._serverItemId !== serverId) return -1;
1533
+ return i;
1534
+ }
1535
+ return -1; // the next real turn starts here; this one has no placeholder
1536
+ }
1537
+ return -1;
1538
+ }
1539
+
675
1540
  resolveQueuedUserBubble(serverId?: string): number | undefined {
676
1541
  // The two fallbacks below match by POSITION, not identity, so they must
677
1542
  // never consider a bubble stamped for a different chat.
@@ -699,9 +1564,7 @@ export class ChatSession {
699
1564
  if (userIdx >= 0) {
700
1565
  var ex = this.state.messages[userIdx];
701
1566
  this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...(ex._ownerKey !== undefined ? { _ownerKey: ex._ownerKey } : {}) };
702
- var thIdx = this.state.messages.findIndex(function (m, i) {
703
- return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
704
- });
1567
+ var thIdx = this._ownThinkingIndex(userIdx, serverId);
705
1568
  if (thIdx !== -1) this.state.messages.splice(thIdx, 1);
706
1569
  }
707
1570
  this.promoteNextQueuedToRunning();
@@ -713,11 +1576,12 @@ export class ChatSession {
713
1576
  if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
714
1577
  if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
715
1578
  if (exist._ts !== undefined) repl._ts = exist._ts;
1579
+ // Kept so the bubble is not re-keyed (and in Vue, remounted) at the moment
1580
+ // its answer arrives; the views prefer _serverItemId, this is the fallback.
1581
+ if (exist._localId !== undefined) repl._localId = exist._localId;
716
1582
  this.state.messages[userIdx] = repl;
717
1583
  }
718
- var thinkingIdx = userIdx >= 0
719
- ? this.state.messages.findIndex(function (m, i) { return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask; })
720
- : -1;
1584
+ var thinkingIdx = this._ownThinkingIndex(userIdx, serverId);
721
1585
  return thinkingIdx !== -1 ? thinkingIdx : (userIdx >= 0 ? userIdx + 1 : -1);
722
1586
  }
723
1587
 
@@ -725,7 +1589,17 @@ export class ChatSession {
725
1589
  // Error/direct replies land here rather than through the typewriter, so this
726
1590
  // is where they pick up their display time.
727
1591
  if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = wallClockNow();
728
- if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) this.state.messages[targetIdx] = msg;
1592
+ // Overwrite only a placeholder that genuinely belongs to the turn above it.
1593
+ // Two things now sit at this index that must never be replaced by a chat
1594
+ // answer: a background "Indexing:" placeholder (a file's rows are inserted
1595
+ // directly above the turn they belong to, so one can land here) — replacing it
1596
+ // deletes a live pass from its collapsed row — and another request's
1597
+ // placeholder, which resolveQueuedUserBubble deliberately declined to claim.
1598
+ // Anything else is spliced in beside it.
1599
+ var tgt = targetIdx >= 0 ? this.state.messages[targetIdx] : undefined;
1600
+ var replaceable = !!tgt && !!tgt.isPending && !tgt.isBackgroundTask &&
1601
+ this._isOwnPlaceholderOf(targetIdx, this._owningUserIndex(targetIdx));
1602
+ if (replaceable) this.state.messages[targetIdx] = msg;
729
1603
  else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
730
1604
  else this.state.messages.push(msg);
731
1605
  }
@@ -832,12 +1706,30 @@ export class ChatSession {
832
1706
  var platform = id.platform;
833
1707
  if (platform !== 'claude' && platform !== 'openai') return;
834
1708
  var url = platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
835
- var queueBase = id.userId || id.serviceId;
836
- var queue = (msg.isBackgroundTask || msg._useBgQueue) ? queueBase + BG_INDEXING_QUEUE_SUFFIX : queueBase;
837
- this.state.messages[idx] = Object.assign({}, msg, { _cancelling: true, _cancelError: undefined });
1709
+ var queueBase = id.userId || id.projectId;
1710
+ var queue = (msg.isBackgroundTask || msg._useBgQueue) ? bgIndexingQueueName(queueBase) : queueBase;
1711
+ // `idx` is the index the VIEW rendered this bubble at, and the list can have
1712
+ // moved under it since: indexing rows are inserted above the turn they belong
1713
+ // to (drainBgTaskQueue), so a pass that starts between the last render and the
1714
+ // click shifts every bubble below it down. Writing blind at a stale index
1715
+ // overwrites an unrelated message with a copy of this one. The server id is the
1716
+ // identity — cancelQueuedMessage already refuses to run without one.
1717
+ // The role test belongs on BOTH arms: a request bubble and its reply share one
1718
+ // _serverItemId, so an id-only fast path would paint "Stopping..." on the
1719
+ // reply whenever the list shifted UP by one instead (a stray placeholder
1720
+ // swept, a sibling pass cancelled, a NOT_EXISTS splice).
1721
+ var at = (this.state.messages[idx] && this.state.messages[idx]._serverItemId === serverId && this.state.messages[idx].role === msg.role)
1722
+ ? idx
1723
+ : this.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.role === msg.role; });
1724
+ // Gone from the list entirely (a history refetch rebuilt it): skip the paint —
1725
+ // writing at the rendered index would clobber a stranger — but still SEND the
1726
+ // cancel. The user asked for it, and the server request is the part that matters.
1727
+ if (at !== -1) {
1728
+ this.state.messages[at] = Object.assign({}, this.state.messages[at], { _cancelling: true, _cancelError: undefined });
1729
+ }
838
1730
  this.host.notify();
839
1731
  Promise.resolve(this.host.cancelRequest({
840
- url: url, method: 'POST', id: serverId, queue: queue, service: id.serviceId, owner: id.owner,
1732
+ url: url, method: 'POST', id: serverId, queue: queue, service: id.projectId, owner: id.owner,
841
1733
  })).then(function (result: any) {
842
1734
  if (result && result.removed) {
843
1735
  self.cancelledServerIds.add(serverId as string);
@@ -898,7 +1790,10 @@ export class ChatSession {
898
1790
  * 2. the file is remembered in cancelledIndexKeys, so the client-driven
899
1791
  * resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
900
1792
  * 3. any of its passes still sitting in bgTaskQueue is dropped by the next
901
- * drain rather than surfacing a fresh "Indexing…" bubble.
1793
+ * drain rather than surfacing a fresh "Indexing…" bubble; and
1794
+ * 4. the RUN is remembered (state.stoppedIndexIds), because none of the above
1795
+ * necessarily leaves a mark on the conversation — see below — and without
1796
+ * it the collapsed row reported the stopped file as finished.
902
1797
  *
903
1798
  * Records already written by the passes that DID run are kept — this stops the
904
1799
  * work, it does not undo it.
@@ -910,6 +1805,51 @@ export class ChatSession {
910
1805
  // _indexKeyOf scopes a queued task's.
911
1806
  var scoped = this.getHistoryCacheKey() + '|' + group.key;
912
1807
  this.cancelledIndexKeys.add(scoped);
1808
+ // Remember WHICH RUN was stopped, by the ids of the passes it is made of.
1809
+ // Everything else here stops the work without leaving any mark on the
1810
+ // conversation: a running pass cannot be un-run (it finishes and writes an
1811
+ // ordinary answer), and a queued continuation is dropped before it is ever
1812
+ // surfaced — so a stopped run's newest bubble is routinely a successful pass,
1813
+ // and the row read "Indexed" over a file the user had just stopped. Ids, not
1814
+ // the file key, so a re-upload or Reindex of the same path is a new run with
1815
+ // new ids and starts clean; and on the reactive state so the row updates the
1816
+ // moment this is recorded, since a stop with nothing left to cancel changes
1817
+ // no message at all.
1818
+ //
1819
+ // Not for a run that is already OVER, though — you cannot stop what has
1820
+ // finished, and marking it stopped would relabel a fully-indexed file as
1821
+ // cancelled on the strength of a click that arrived too late. (`finished` is
1822
+ // only ever positively established: "still running" and "we have not found
1823
+ // out" both read as false here, so the doubt goes to recording the stop.)
1824
+ if (!group.finished) {
1825
+ var stoppedIds: { [id: string]: boolean } = {};
1826
+ for (var sk in this.state.stoppedIndexIds) stoppedIds[sk] = true;
1827
+ (group.members || []).forEach(function (m) {
1828
+ var sid = m && m.msg && m.msg._serverItemId;
1829
+ if (sid) stoppedIds[sid] = true;
1830
+ });
1831
+ // The queue's own view of this file, which is not always the row's: an
1832
+ // entry can be waiting for its bubble, and the FIRST pass's entry outlives
1833
+ // the moment its bubble settles. _applyIndexCancellations reads this to
1834
+ // tell a pass that existed when the user hit Stop from a genuinely new
1835
+ // indexing request, and only the latter is allowed to lift the stop.
1836
+ this.bgTaskQueue.forEach(function (e) {
1837
+ if (e && e.id && self._indexKeyOf(e) === scoped) stoppedIds[e.id] = true;
1838
+ });
1839
+ this.state.stoppedIndexIds = stoppedIds;
1840
+ }
1841
+ // Ask the QUEUE what else is live for this file, and cancel that too.
1842
+ //
1843
+ // The ids above are the passes THIS client knows about, and for a
1844
+ // worker-driven file that is routinely none of them: the worker mints the
1845
+ // next window itself and the client only learns its id on a later look. A
1846
+ // stop that reaches no server row at all is a stop that dies with the tab —
1847
+ // the chain keeps running, and the only record of the stop is this page's
1848
+ // memory. The adopt ladder is the existing machinery for exactly this
1849
+ // question; anything it turns up for a stopped file is dropped and
1850
+ // server-cancelled by _applyIndexCancellations on the next drain, which is
1851
+ // also what makes the worker stop enqueueing windows.
1852
+ this._adoptWorkerIndexingPasses(0);
913
1853
  var ids = group.cancellableIds || [];
914
1854
  if (!ids.length) { this.host.notify(); return; }
915
1855
  ids.forEach(function (serverId) {
@@ -1083,23 +2023,77 @@ export class ChatSession {
1083
2023
  return this.enqueueTypewrite(pendingIdx, latest.content, lid);
1084
2024
  }
1085
2025
 
1086
- // Remove any leftover non-background pending ("Thinking…") assistant bubbles.
1087
- // There is normally at most ONE such bubble at a time (promoteNext* refuses to
1088
- // add a second), so any extra is a duplicate it appears when a concurrent
1089
- // history refetch re-maps the still-"running" turn into a pending placeholder
1090
- // (with a real _serverItemId) while the local pending bubble (no _serverItemId)
1091
- // is rescued and re-appended (see loadHistory rescue below). Each resolve path
1092
- // only replaces the FIRST pending bubble, so without this a stray "Thinking…"
1093
- // survives next to the reply/error. MUST run AFTER the resolved bubble has been
1094
- // made non-pending and BEFORE promoteNext*() (so a freshly-promoted Thinking,
1095
- // which is added only once no pending assistant remains, is preserved).
2026
+ // Remove leftover non-background pending ("Thinking…") assistant bubbles: the
2027
+ // duplicate that appears when a concurrent history refetch re-maps the still-
2028
+ // "running" turn into a pending placeholder (with a real _serverItemId) while the
2029
+ // local pending bubble (no _serverItemId) is rescued and re-appended (see the
2030
+ // loadHistory rescue below), and the orphan a resolve leaves when it splices its
2031
+ // reply beside a placeholder instead of into it. Each resolve path only replaces
2032
+ // ONE pending bubble, so without this a stray "Thinking…" survives forever next to
2033
+ // the reply. MUST run AFTER the resolved bubble has been made non-pending and
2034
+ // BEFORE promoteNext*() (which only adds a Thinking once none remains).
2035
+ //
2036
+ // It used to take EVERY one, on the premise that there is at most one at a time
2037
+ // because promoteNext* refuses to add a second. That premise never covered the
2038
+ // immediate-send path, which creates its pair directly — and a turn sent with
2039
+ // attachments does not block the composer and resolves on its own queue, so an
2040
+ // ordinary question asked while files index is in flight, with a placeholder of
2041
+ // its own, exactly when the attachment turn resolves. Sweeping it left that
2042
+ // question with no spinner and, worse, nowhere for its answer to land:
2043
+ // typewriteLatestReply bails when there is no pending assistant, so the reply
2044
+ // reached the cache and never the screen.
2045
+ //
2046
+ // The discriminator is the owning USER bubble. A live immediate send's user bubble
2047
+ // carries NO pending flags (its in-flight-ness lives in state.sending), while every
2048
+ // duplicate this sweep is for belongs to a user bubble that is still pending — and
2049
+ // an orphan has no user bubble above it at all.
1096
2050
  _removeStrayPendingAssistants(): void {
1097
2051
  for (var k = this.state.messages.length - 1; k >= 0; k--) {
1098
2052
  var m = this.state.messages[k];
1099
- if (m.isPending && m.role === 'assistant' && !m.isBackgroundTask) this.state.messages.splice(k, 1);
2053
+ if (!m || !m.isPending || m.role !== 'assistant' || m.isBackgroundTask) continue;
2054
+ if (this._isLiveImmediatePlaceholder(k)) continue;
2055
+ this.state.messages.splice(k, 1);
1100
2056
  }
1101
2057
  }
1102
2058
 
2059
+ /** Index of the USER bubble the message at `idx` belongs to — the nearest one
2060
+ * above it, stepping over background bubbles (a file's indexing rows are
2061
+ * inserted between turns). -1 when the nearest thing above is not a user turn,
2062
+ * which for a placeholder means it is an orphan. */
2063
+ private _owningUserIndex(idx: number): number {
2064
+ for (var j = idx - 1; j >= 0; j--) {
2065
+ var p = this.state.messages[j];
2066
+ if (!p) return -1;
2067
+ if (p.isBackgroundTask) continue;
2068
+ return p.role === 'user' ? j : -1;
2069
+ }
2070
+ return -1;
2071
+ }
2072
+
2073
+ /** The bubble at `idx` is the "Thinking…" of a DIFFERENT turn that is still
2074
+ * waiting for its answer, so the sweep above must leave it alone. */
2075
+ private _isLiveImmediatePlaceholder(idx: number): boolean {
2076
+ var ui = this._owningUserIndex(idx);
2077
+ if (ui === -1) return false; // orphan
2078
+ var p = this.state.messages[ui];
2079
+ // Its own turn, still unanswered and not one of the pending duplicates: a live
2080
+ // immediate send's user bubble carries no pending flags at all.
2081
+ return !p.isPending && !p.isPendingQueued && !p.isPendingInProcess &&
2082
+ !p.isPendingOlder && !p.isSendingToServer && !p.isCancelled;
2083
+ }
2084
+
2085
+ /** A pending assistant at `idx` is the placeholder OF the turn above it, so a
2086
+ * reply may take its slot. Every path that makes one copies the parent's
2087
+ * _serverItemId (or neither has one yet), so a mismatch means the slot belongs to
2088
+ * some other request and the reply must be spliced in beside it, not on top. */
2089
+ private _isOwnPlaceholderOf(idx: number, userIdx: number): boolean {
2090
+ if (userIdx === -1) return false;
2091
+ var ph = this.state.messages[idx], u = this.state.messages[userIdx];
2092
+ if (!ph || !u) return false;
2093
+ if (ph._serverItemId === undefined || u._serverItemId === undefined) return true;
2094
+ return ph._serverItemId === u._serverItemId;
2095
+ }
2096
+
1103
2097
  // Drop the pending flags on the resolved turn's USER bubble (preserving its
1104
2098
  // content + background-task marker). Needed because a bg "Indexing:" turn's user
1105
2099
  // bubble carries isPendingInProcess; leaving it set keeps the bubble visually
@@ -1151,6 +2145,11 @@ export class ChatSession {
1151
2145
  var indexRef = this._indexRefOfItem(itemId);
1152
2146
  this.applyHistoryItemResolution(itemId, response, platform);
1153
2147
  this.promoteNextBgQueuedToRunning();
2148
+ // applyHistoryItemResolution just released this item's poll slot. Under
2149
+ // MAX_CONCURRENT_BG_POLLS that slot is what the next-oldest unpolled entry
2150
+ // is waiting for, and every settling turn funnels through here — including
2151
+ // the history poll, whose resolution has no other path back to the drain.
2152
+ this.drainBgTaskQueue();
1154
2153
  // A worker-driven chain has no client-side record of its next pass, so a
1155
2154
  // settling pass is the only moment there is to go looking for one.
1156
2155
  if (indexRef) this._followWorkerIndexingChain(indexRef.name, indexRef.mime);
@@ -1168,13 +2167,85 @@ export class ChatSession {
1168
2167
  return null;
1169
2168
  }
1170
2169
 
2170
+ /**
2171
+ * Settle a turn the server reports as cancelled: the request bubble goes to its
2172
+ * cancelled form and the "Thinking..." placeholder goes away. The same shape
2173
+ * cancelQueuedMessage produces locally, so a cancel this client made and one it
2174
+ * merely found out about render identically — and an indexing pass keeps the
2175
+ * markers that hold it in its file's collapsed row.
2176
+ */
2177
+ private _settleCancelledItem(itemId: string): void {
2178
+ var uIdx = this.state.messages.findIndex(function (m) {
2179
+ return m.role === 'user' && m._serverItemId === itemId && !m.isCancelled;
2180
+ });
2181
+ if (uIdx !== -1) {
2182
+ var u = this.state.messages[uIdx];
2183
+ var cancelled: ChatMessage = { role: 'user', content: u.content, isCancelled: true, _serverItemId: itemId };
2184
+ if (u.isBackgroundTask) cancelled.isBackgroundTask = true;
2185
+ if (u._indexFile) cancelled._indexFile = u._indexFile;
2186
+ if (u._useBgQueue) cancelled._useBgQueue = true;
2187
+ if (u._ownerKey !== undefined) cancelled._ownerKey = u._ownerKey;
2188
+ if (u._ts !== undefined) cancelled._ts = u._ts;
2189
+ this.state.messages[uIdx] = cancelled;
2190
+ }
2191
+ var pIdx = this.state.messages.findIndex(function (m) {
2192
+ return m.isPending && m.role === 'assistant' && m._serverItemId === itemId;
2193
+ });
2194
+ if (pIdx !== -1) this.state.messages.splice(pIdx, 1);
2195
+ this.cancelledServerIds.delete(itemId);
2196
+ this._removeStrayPendingAssistants();
2197
+ this.host.notify();
2198
+ this.updateHistoryCache();
2199
+ }
2200
+
2201
+ /**
2202
+ * A poll that came back saying the request was CANCELLED, rather than with an
2203
+ * answer.
2204
+ *
2205
+ * The server keeps a cancelled request as a terminal row instead of deleting it
2206
+ * (that row is the durable record of the stop, and the chat history it belongs
2207
+ * to), so a poll still running when the cancel lands now RESOLVES on it. It used
2208
+ * to reject with NOT_EXISTS, and the resolution path below reads a status object
2209
+ * as an answer with no text — which would stamp "No text response received from
2210
+ * AI provider" over a turn the user had just stopped.
2211
+ *
2212
+ * Reachable whenever the poll was not stopped by whoever cancelled: another tab,
2213
+ * another device, or the row being cancelled server-side by the file's own stop.
2214
+ */
2215
+ private _isCancelledPollResult(response: any): boolean {
2216
+ if (!response || typeof response !== 'object' || response.status !== 'cancelled') return false;
2217
+ // The POLL's own shape (id + queue fields, no provider payload), not a
2218
+ // provider body that happens to carry a `status` — OpenAI's Responses API
2219
+ // has a "cancelled" status of its own, and that one is an answer to render,
2220
+ // not a row that was removed from the queue.
2221
+ if (response.content !== undefined || response.output !== undefined) return false;
2222
+ return response.queue_name !== undefined || response.in_queue !== undefined;
2223
+ }
2224
+
1171
2225
  applyHistoryItemResolution(itemId: string, response: any, platform: string): void {
1172
2226
  this.historyItemPolls.delete(itemId);
2227
+ if (this._isCancelledPollResult(response)) {
2228
+ this._settleCancelledItem(itemId);
2229
+ return;
2230
+ }
1173
2231
  var isErr = isErrorResponseBody(response);
1174
2232
  var answer = isErr ? getErrorMessage(response)
1175
2233
  : ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim();
1176
- // Hide the internal indexing-completion marker from the displayed summary.
1177
- if (!isErr && answer) answer = answer.split(INDEXING_COMPLETE_MARKER).join('').trim();
2234
+ // Record the marker BEFORE hiding it from the displayed summary: the display
2235
+ // layer needs it as a structured fact, and searching the shown text for it
2236
+ // afterwards would find nothing. Mirrors history.ts, so a run reads the same
2237
+ // live and after a reload.
2238
+ //
2239
+ // The STRIP happens in the background branches only, not here. It used to run
2240
+ // on every answer, so an ordinary chat reply that merely mentioned the token
2241
+ // had it silently cut out of the user's text (leaving a double space) — and
2242
+ // the history mappers never did that, so the same reply read differently
2243
+ // before and after a reload. Only an INDEXING pass has a protocol token to
2244
+ // hide.
2245
+ var reportedComplete = !isErr && !!answer && answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
2246
+ var stripMarker = function (t: string): string {
2247
+ return reportedComplete ? t.split(INDEXING_COMPLETE_MARKER).join('').trim() : t;
2248
+ };
1178
2249
  var idx = this.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === itemId; });
1179
2250
  if (idx !== -1) {
1180
2251
  // A bg "Indexing:" turn pushes a user bubble (isPendingInProcess) ALONGSIDE
@@ -1205,7 +2276,7 @@ export class ChatSession {
1205
2276
  // history, and ran a per-frame scroll for content nobody can see.
1206
2277
  // Write it straight in; expanding the row then shows it complete.
1207
2278
  if (wasBgTask) {
1208
- this.state.messages[idx] = { role: 'assistant', content: text, isBackgroundTask: true, _serverItemId: itemId };
2279
+ this.state.messages[idx] = { role: 'assistant', content: stripMarker(text) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...(reportedComplete ? { _indexComplete: true } : {}) };
1209
2280
  this.host.notify(); this.updateHistoryCache(); return;
1210
2281
  }
1211
2282
  var lid = this._newLocalId();
@@ -1237,7 +2308,7 @@ export class ChatSession {
1237
2308
  // Same as above: a collapsed row's reply is not on screen, so revealing it
1238
2309
  // character by character only blocks the queue the visible reply needs.
1239
2310
  if (ex.isBackgroundTask) {
1240
- this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: text2, isBackgroundTask: true, _serverItemId: itemId });
2311
+ this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: stripMarker(text2) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...(reportedComplete ? { _indexComplete: true } : {}) });
1241
2312
  this.host.notify(); this.updateHistoryCache(); return;
1242
2313
  }
1243
2314
  var lid2 = this._newLocalId();
@@ -1256,7 +2327,7 @@ export class ChatSession {
1256
2327
  if (!entry) return '';
1257
2328
  var file = entry.storagePath || entry.filename;
1258
2329
  if (!file) return '';
1259
- return entry.serviceId + '#' + entry.platform + '|' + file;
2330
+ return entry.projectId + '#' + entry.platform + '|' + file;
1260
2331
  }
1261
2332
 
1262
2333
  /**
@@ -1267,14 +2338,38 @@ export class ChatSession {
1267
2338
  * path, and without this an earlier cancel would silently kill every future
1268
2339
  * index of the same path. A continuation of a stopped file is dropped instead,
1269
2340
  * covering the pass that was dispatched in the moment before the cancel landed.
2341
+ *
2342
+ * "Fresh" is the load-bearing word, and it used to be missing. A run's OWN first
2343
+ * pass sits in this queue for as long as it runs (entries are only dropped once
2344
+ * their bubble settles), so stopping a file during its first pass — which is
2345
+ * exactly when a user who has just uploaded it does — met that first-pass entry
2346
+ * on the very next drain and lifted the stop the user had just asked for. The
2347
+ * chain then carried on, one worker-minted window after another, with nothing
2348
+ * client-side left to suppress it. The ids recorded at stop time are what tells
2349
+ * the two apart: a pass that was already there when the user hit Stop cannot be
2350
+ * the new request that lifts it.
1270
2351
  */
1271
2352
  private _applyIndexCancellations(): void {
1272
2353
  if (!this.cancelledIndexKeys.size) return;
2354
+ // Passes already on screen. Dropping the queue entry for one of those kills
2355
+ // its poll and leaves the bubble pending forever — a row stuck on "Indexing"
2356
+ // with no way to ever settle. _sweepCancelledIndexing (which runs straight
2357
+ // after this, on the same drain) cancels those properly, rebuilding the bubble
2358
+ // as cancelled; the splice below is only right for an entry with no bubble yet.
2359
+ var surfaced: { [id: string]: boolean } = {};
2360
+ this.state.messages.forEach(function (m) {
2361
+ if (!m._serverItemId) return;
2362
+ if (m.isPending || m.isPendingQueued || m.isPendingInProcess) surfaced[m._serverItemId] = true;
2363
+ });
1273
2364
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
1274
2365
  var entry = this.bgTaskQueue[i];
1275
2366
  var key = this._indexKeyOf(entry);
1276
2367
  if (!key || !this.cancelledIndexKeys.has(key)) continue;
1277
- if (!entry.resumePass) { this.cancelledIndexKeys.delete(key); continue; }
2368
+ if (!entry.resumePass && !this.state.stoppedIndexIds[entry.id]) {
2369
+ this.cancelledIndexKeys.delete(key);
2370
+ continue;
2371
+ }
2372
+ if (surfaced[entry.id]) continue;
1278
2373
  this.bgTaskQueue.splice(i, 1);
1279
2374
  this._stopPoll(entry.id);
1280
2375
  this._cancelServerItem(entry.id);
@@ -1361,10 +2456,10 @@ export class ChatSession {
1361
2456
  if (this._adoptingWorkerPasses) return;
1362
2457
  var id = this.host.getIdentity();
1363
2458
  var platform = id.platform;
1364
- if (!id.serviceId || (platform !== 'claude' && platform !== 'openai')) return;
2459
+ if (!id.projectId || (platform !== 'claude' && platform !== 'openai')) return;
1365
2460
  if (this.isPollingPaused() || !this.host.isViewMounted()) return;
1366
- var svcId = id.serviceId, owner = id.owner;
1367
- var queue = (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX;
2461
+ var svcId = id.projectId, owner = id.owner;
2462
+ var queue = bgIndexingQueueName(id.userId, id.projectId);
1368
2463
  var ask = function (status: 'pending' | 'running') {
1369
2464
  return Promise.resolve(getChatHistory(
1370
2465
  { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
@@ -1377,8 +2472,16 @@ export class ChatSession {
1377
2472
  // The chat may have changed under the query; adopting into another
1378
2473
  // project's session is the cross-project bubble leak all over again.
1379
2474
  var now = self.host.getIdentity();
1380
- if (now.serviceId !== svcId || now.platform !== platform) return;
2475
+ if (now.projectId !== svcId || now.platform !== platform) return;
1381
2476
  if (!self.host.isViewMounted()) return;
2477
+ // Free: this is already the whole-queue snapshot the display layer needs to
2478
+ // tell a worker-driven run's "between passes" from its "finished".
2479
+ //
2480
+ // A null is a FAILED query, and the adoption loop below reads it as an empty
2481
+ // list — harmless there (nothing to adopt) but not here: recording it would
2482
+ // publish "the queue holds nothing" on the strength of a request that never
2483
+ // answered, which reads as finished. Not knowing is the honest state.
2484
+ if (results[0] !== null && results[1] !== null) self._recordLiveIndexKeys(results);
1382
2485
  var adoptedIds: string[] = [];
1383
2486
  for (var ri = 0; ri < results.length; ri++) {
1384
2487
  var list = results[ri] && Array.isArray(results[ri].list) ? results[ri].list : [];
@@ -1402,10 +2505,16 @@ export class ChatSession {
1402
2505
  // empty queue for a chain that is very much alive — and with no pass
1403
2506
  // left to settle, nothing would ever ask again. Look once or twice more
1404
2507
  // before believing the file is finished.
1405
- if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) return;
2508
+ if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) {
2509
+ // The ladder is the only thing that can tell a worker-driven chain has
2510
+ // really ended, and it just did. This is the earliest honest moment to
2511
+ // let a turn waiting on these files stop waiting.
2512
+ self._nudgeIndexingDrain();
2513
+ return;
2514
+ }
1406
2515
  setTimeout(function () {
1407
2516
  var later = self.host.getIdentity();
1408
- if (later.serviceId !== svcId || later.platform !== platform) return;
2517
+ if (later.projectId !== svcId || later.platform !== platform) return;
1409
2518
  if (self.isPollingPaused() || !self.host.isViewMounted()) return;
1410
2519
  self._adoptWorkerIndexingPasses(attempt + 1);
1411
2520
  }, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
@@ -1460,7 +2569,7 @@ export class ChatSession {
1460
2569
  // chain's passes — which is the cap that stops it running forever.
1461
2570
  if (!this._isWorkerDrivenIndexing(ref.name, ref.mime)) return false;
1462
2571
  this.bgTaskQueue.push({
1463
- serviceId: svcId,
2572
+ projectId: svcId,
1464
2573
  platform: platform,
1465
2574
  id: item.id,
1466
2575
  filename: ref.name,
@@ -1491,8 +2600,8 @@ export class ChatSession {
1491
2600
  var url = id.platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
1492
2601
  Promise.resolve(this.host.cancelRequest({
1493
2602
  url: url, method: 'POST', id: serverId,
1494
- queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
1495
- service: id.serviceId, owner: id.owner,
2603
+ queue: bgIndexingQueueName(id.userId, id.projectId),
2604
+ service: id.projectId, owner: id.owner,
1496
2605
  })).catch(function () { /* the pass may already have finished; nothing to do */ });
1497
2606
  }
1498
2607
 
@@ -1500,7 +2609,7 @@ export class ChatSession {
1500
2609
  drainBgTaskQueue(): void {
1501
2610
  var self = this;
1502
2611
  var id = this.host.getIdentity();
1503
- var svcId = id.serviceId, plat = id.platform;
2612
+ var svcId = id.projectId, plat = id.platform;
1504
2613
  if (!svcId || plat === 'none' || !this.host.isViewMounted()) return;
1505
2614
  // Before anything is surfaced: drop continuations of files the user stopped
1506
2615
  // (and let a fresh first pass lift the stop), then cancel any worker-queued
@@ -1521,11 +2630,20 @@ export class ChatSession {
1521
2630
  });
1522
2631
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
1523
2632
  var e = this.bgTaskQueue[i];
1524
- if (e.serviceId !== svcId || e.platform !== plat) continue;
2633
+ if (e.projectId !== svcId || e.platform !== plat) continue;
1525
2634
  if (presentIds[e.id] && !pendingIds[e.id]) this.bgTaskQueue.splice(i, 1);
1526
2635
  }
2636
+ // Poll budget for this drain (see MAX_CONCURRENT_BG_POLLS). bgTaskQueue is
2637
+ // in push order, i.e. oldest first, which is also the order the server
2638
+ // settles them in — so spending the budget from the front spends it on the
2639
+ // only entries that can resolve next. Bubbles are still injected for EVERY
2640
+ // entry; only the polling is rationed, and an unpolled entry picks up a
2641
+ // poll on the drain that follows the next resolution.
2642
+ var bgPollBudget = MAX_CONCURRENT_BG_POLLS - this._countBgPolls();
2643
+ // Set by the injection branch; drives the single render/cache/scroll below.
2644
+ var injectedAny = false;
1527
2645
  this.bgTaskQueue.forEach(function (entry) {
1528
- if (entry.serviceId !== svcId || entry.platform !== plat) return;
2646
+ if (entry.projectId !== svcId || entry.platform !== plat) return;
1529
2647
  // Bubble injection and poll attachment are INDEPENDENT. An entry whose bubble
1530
2648
  // already exists may still need a poll — that is exactly the state a paused
1531
2649
  // drain leaves behind, and returning early here stranded it as a permanent
@@ -1549,14 +2667,50 @@ export class ChatSession {
1549
2667
  },
1550
2668
  };
1551
2669
  if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
1552
- self.state.messages.push(userBubble);
1553
- if (isRunning) {
1554
- self.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
2670
+ // Directly ABOVE the chat turn these files were attached to, when that turn
2671
+ // is still on screen as a staged bubble. The row then appears where it
2672
+ // belongs from the start right before the message the files came with,
2673
+ // which is also the order the server reports once the turn is sent (its
2674
+ // request id is newer than every pass it waits for). Appending instead put
2675
+ // the row BELOW the message and left the turn to be moved down past it at
2676
+ // dispatch, minutes later: a swap under the reader, at the one moment they
2677
+ // were watching for the turn to go out.
2678
+ //
2679
+ // -1 (no stage id, or a staged bubble already replaced/rebuilt away) appends,
2680
+ // which is the old behaviour and the right one for work with no chat turn
2681
+ // behind it: the dbfile page, an attachment-only send, a worker-adopted pass.
2682
+ // Resolved PER ENTRY: one drain can inject several passes, and a hoisted
2683
+ // index would insert each one before the last and reverse them.
2684
+ var stageAt = self._stageIndex(self.state.messages, entry.stageId);
2685
+ var runningBubble: ChatMessage | null = isRunning
2686
+ ? { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id }
2687
+ : null;
2688
+ if (stageAt === -1) {
2689
+ self.state.messages.push(userBubble);
2690
+ if (runningBubble) self.state.messages.push(runningBubble);
2691
+ } else if (runningBubble) {
2692
+ // One splice, so the request and its placeholder stay ADJACENT: an
2693
+ // id-less response bubble is attributed to the message immediately
2694
+ // above it (indexing_groups) and replies are spliced at userIdx + 1.
2695
+ self.state.messages.splice(stageAt, 0, userBubble, runningBubble);
2696
+ } else {
2697
+ // AT the staged bubble, so this pass lands after any pass already
2698
+ // inserted for the same turn — files keep their upload order.
2699
+ self.state.messages.splice(stageAt, 0, userBubble);
1555
2700
  }
1556
2701
  presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
1557
- self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottomIfSticky(false);
2702
+ // Render/cache/scroll are hoisted OUT of this loop (see injectedAny below).
2703
+ // They used to run per injected entry, and updateHistoryCache filters the
2704
+ // whole of state.messages into a fresh array each call — so a bulk upload
2705
+ // injecting one bubble per file did O(files x messages) element visits and
2706
+ // left one throwaway array per file for the GC. In the widget it was worse
2707
+ // still: its notify() is a full DOM re-render of the message list. The end
2708
+ // state is identical either way; only the discarded intermediate renders
2709
+ // are gone.
2710
+ injectedAny = true;
1558
2711
  }
1559
- if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
2712
+ if (bgPollBudget > 0 && !self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
2713
+ bgPollBudget--;
1560
2714
  var capturedId = entry.id, capturedPlat = plat;
1561
2715
  var capturedEntry = entry;
1562
2716
  var wasStopped = false;
@@ -1593,15 +2747,39 @@ export class ChatSession {
1593
2747
  }
1594
2748
  }
1595
2749
  self.host.notify(); self.updateHistoryCache();
2750
+ // A pass that FAILED dispatches no continuation on any path, so a
2751
+ // CLIENT-driven file is finished either way — let a turn waiting on it
2752
+ // look now. Not for a worker-driven one: this catch also fires on a
2753
+ // dropped poll, and the worker may have resolved that pass and written
2754
+ // the next one regardless. Nudging there would pull the two confirming
2755
+ // looks into the window where the new pass is not yet in the status
2756
+ // index, which is the same way a nudge on work STARTING went wrong.
2757
+ if (!self._isWorkerDrivenIndexing(capturedEntry.filename, capturedEntry.mime)) {
2758
+ self._nudgeIndexingDrain();
2759
+ }
1596
2760
  }).then(function () {
1597
2761
  // Keep the queue entry when the poll was merely stopped, or resuming
1598
2762
  // would have nothing left to re-attach to.
1599
2763
  if (wasStopped) return;
1600
2764
  var qi = self.bgTaskQueue.findIndex(function (q) { return q.id === capturedId; });
1601
2765
  if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
2766
+ // This resolution freed a slot in the MAX_CONCURRENT_BG_POLLS budget,
2767
+ // so hand it to the next-oldest unpolled entry. Load-bearing under the
2768
+ // cap: with thousands of entries queued, only the handful holding
2769
+ // polls can ever settle, and without this re-drain the remaining
2770
+ // entries would sit unpolled forever. Not left to a host-side watcher
2771
+ // on bgTaskQueue — agent.vue has one, the widget does not.
2772
+ self.drainBgTaskQueue();
1602
2773
  });
1603
2774
  }
1604
2775
  });
2776
+ // One render, one cache write, one scroll for the whole drain — however many
2777
+ // bubbles it injected. See the injectedAny comment above.
2778
+ if (injectedAny) {
2779
+ this.host.notify();
2780
+ this.updateHistoryCache();
2781
+ this.host.scrollToBottomIfSticky(false);
2782
+ }
1605
2783
  this.promoteNextBgQueuedToRunning();
1606
2784
  }
1607
2785
 
@@ -1619,13 +2797,21 @@ export class ChatSession {
1619
2797
  // as well would now double-index every window.
1620
2798
  maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
1621
2799
  var self = this;
2800
+ // This client is the ONLY driver of the chains that reach the returns below,
2801
+ // so its decision not to continue one is authoritative and immediate — the
2802
+ // earliest honest "these files are done" a waiting turn can get. Used only to
2803
+ // make awaitIndexingDrained look sooner; it still has to agree twice.
2804
+ // NOT called for the worker-driven returns further down (the worker may be
2805
+ // about to write the next pass) or for a stopped file (its queued passes are
2806
+ // still being cancelled).
2807
+ var endOfClientChain = function () { self._nudgeIndexingDrain(); };
1622
2808
  try {
1623
2809
  if (!entry || !entry.storagePath) return;
1624
2810
  // The user stopped this file from its collapsed row. Dispatching the next
1625
2811
  // pass here is exactly what "stop" has to prevent — the cancelled pass
1626
2812
  // settles, and without this the chain simply carries on.
1627
2813
  if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
1628
- if (!isPagedReadFile(entry.filename, entry.mime)) return;
2814
+ if (!isPagedReadFile(entry.filename, entry.mime)) { endOfClientChain(); return; }
1629
2815
  if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop (PDF vision)
1630
2816
  // When windowed indexing is on, the WORKER drives the text/grid loop too. The
1631
2817
  // client MUST NOT also resume, or two drivers each enqueue a continuation per
@@ -1633,19 +2819,27 @@ export class ChatSession {
1633
2819
  // PDFs early-return above. Gated on the flag so the old client-driven path is
1634
2820
  // untouched when windowing is off.
1635
2821
  if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
1636
- if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
2822
+ if (isErrorResponseBody(response)) { endOfClientChain(); return; } // a failed pass is not "incomplete"
1637
2823
  var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
1638
- if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
2824
+ if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) { endOfClientChain(); return; } // fully indexed
1639
2825
  var pass = (entry.resumePass || 0) + 1;
1640
- if (pass > MAX_INDEXING_RESUME_PASSES) return; // give up after the cap
2826
+ if (pass > MAX_INDEXING_RESUME_PASSES) { endOfClientChain(); return; } // give up after the cap
1641
2827
  var id = this.host.getIdentity();
1642
- if (!id || id.platform === 'none' || id.serviceId !== entry.serviceId) return;
1643
- notifyAgentContinueIndexing({
2828
+ if (!id || id.platform === 'none' || id.projectId !== entry.projectId) return;
2829
+ // Counted as live work from here, not from the ack: awaitIndexingDrained
2830
+ // asks the SERVER what is queued, and this pass is not queued until the
2831
+ // call below returns. Without it a chat can slip in between two passes.
2832
+ this.trackIndexDispatch(notifyAgentContinueIndexing({
1644
2833
  platform: id.platform as 'claude' | 'openai',
1645
2834
  model: id.model,
1646
- service: id.serviceId,
2835
+ service: id.projectId,
2836
+ // Without this the resume pass rebuilds its system prompt from the RAW
2837
+ // regional id (requests.ts falls back to `service`), and the model copies
2838
+ // that id verbatim into project_id tool calls, which the MCP schema
2839
+ // pattern rejects - the whole continue pass saves nothing.
2840
+ publicProjectId: id.publicProjectId,
1647
2841
  owner: id.owner,
1648
- userId: id.userId || id.serviceId,
2842
+ userId: id.userId || id.projectId,
1649
2843
  serviceName: id.serviceName,
1650
2844
  serviceDescription: id.serviceDescription,
1651
2845
  attachment: {
@@ -1658,15 +2852,22 @@ export class ChatSession {
1658
2852
  }).then(function (ack: any) {
1659
2853
  if (ack && typeof ack.id === 'string') {
1660
2854
  self.bgTaskQueue.push({
1661
- serviceId: id.serviceId, platform: id.platform as 'claude' | 'openai', id: ack.id,
2855
+ projectId: id.projectId, platform: id.platform as 'claude' | 'openai', id: ack.id,
1662
2856
  filename: entry.filename, storagePath: entry.storagePath,
1663
2857
  isReindex: entry.isReindex, mime: entry.mime, size: entry.size,
1664
2858
  status: ack.status === 'running' ? 'running' : 'pending',
1665
2859
  poll: ack.poll, resumePass: pass,
2860
+ // Deliberately NOT stamped with entry.stageId. Only a batch's FIRST
2861
+ // pass anchors to the turn; a continuation appends, which is the
2862
+ // order the server queued it in and therefore the order
2863
+ // promoteNextBgQueuedToRunning should spin it in. It costs nothing
2864
+ // on screen: a continuation is folded into the run whose row
2865
+ // already sits above the turn, and renders nothing at its own
2866
+ // index (indexing_groups anchors a run at its FIRST loaded pass).
1666
2867
  });
1667
2868
  self.drainBgTaskQueue();
1668
2869
  }
1669
- }, function (e: any) { console.error('[chat-engine] resume-indexing dispatch failed', e); });
2870
+ }, function (e: any) { console.error('[chat-engine] resume-indexing dispatch failed', e); }));
1670
2871
  } catch (e) { /* best-effort: resume must never break bg-task resolution */ }
1671
2872
  }
1672
2873
 
@@ -1683,21 +2884,29 @@ export class ChatSession {
1683
2884
  // request is built from. The rescue below compares against this rather
1684
2885
  // than a live getHistoryCacheKey(), so a project switch mid-fetch can't
1685
2886
  // make another chat's in-flight bubbles look local.
1686
- var loadKey = (!id.serviceId || id.platform === 'none') ? '' : id.serviceId + '#' + id.platform;
2887
+ var loadKey = (!id.projectId || id.platform === 'none') ? '' : id.projectId + '#' + id.platform;
1687
2888
  if (token === undefined) token = this.state.gateRefreshToken;
1688
- if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.serviceId) {
2889
+ if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.projectId) {
1689
2890
  return Promise.resolve();
1690
2891
  }
1691
2892
  this.state.historyRequestToken = token;
1692
2893
  this.state.loadingHistory = true;
2894
+ // A first-page load is the one moment the chat may have changed under us. The
2895
+ // old snapshot describes the previous project's queue; keeping it would let a
2896
+ // row here claim it was finished on evidence from somewhere else. Re-seeded
2897
+ // when this load lands.
2898
+ if (!fetchMore && loadKey !== this._liveIndexKey) {
2899
+ this._liveIndexKey = loadKey;
2900
+ this._resetLiveIndexKeys();
2901
+ }
1693
2902
  if (fetchMore) this.state.loadingOlderHistory = true;
1694
2903
  this.host.notify(); // surface "Fetching history..." while it loads
1695
2904
  var platform = id.platform as 'claude' | 'openai';
1696
- var serviceId = id.serviceId, owner = id.owner;
2905
+ var projectId = id.projectId, owner = id.owner;
1697
2906
  var options: any = { fetchMore: fetchMore };
1698
2907
  if (fetchMore && this.state.historyStartKeyHistory.length) options.startKeyHistory = this.state.historyStartKeyHistory.slice();
1699
2908
 
1700
- var fetchHistory = function () { return getChatHistory({ service: serviceId, owner: owner, platform: platform }, options); };
2909
+ var fetchHistory = function () { return getChatHistory({ service: projectId, owner: owner, platform: platform }, options); };
1701
2910
 
1702
2911
  return Promise.resolve().then(fetchHistory).catch(function (err: any) {
1703
2912
  if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
@@ -1717,7 +2926,7 @@ export class ChatSession {
1717
2926
  });
1718
2927
  var mapped = mapHistoryListToMessages(list, platform, {
1719
2928
  clearedAt: self.host.getClearedAt(),
1720
- serviceId: id.serviceId,
2929
+ projectId: id.projectId,
1721
2930
  formatIndexingLabel: self.host.formatIndexingLabel,
1722
2931
  }).messages;
1723
2932
 
@@ -1763,6 +2972,13 @@ export class ChatSession {
1763
2972
  if (mm._ownerKey !== undefined && mm._ownerKey !== loadKey) continue;
1764
2973
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
1765
2974
  if (!mm._serverItemId) {
2975
+ // A staged turn (files still uploading) has no server request
2976
+ // yet, so nothing in `mapped` can stand for it — rescue it
2977
+ // unconditionally. The mappedHasPendingAssistant skip below is
2978
+ // about a turn the server ALREADY has; applying it here would
2979
+ // delete the user's message mid-upload whenever some other
2980
+ // turn happened to be in flight.
2981
+ if (mm._stageId) { rescued.push(mm); continue; }
1766
2982
  if (mappedHasPendingAssistant) continue;
1767
2983
  if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
1768
2984
  else if (self.state.sending && mm.role === 'user') {
@@ -1856,6 +3072,25 @@ export class ChatSession {
1856
3072
  self.host.notify();
1857
3073
 
1858
3074
  if (!fetchMore) {
3075
+ // Ration BACKGROUND polls here exactly as the drain does (see
3076
+ // MAX_CONCURRENT_BG_POLLS); foreground items — a reply the user is
3077
+ // waiting on — are never rationed. The allow-set is taken from the
3078
+ // OLDEST bg items: ids sort with newest largest (the same ordering the
3079
+ // older-page merge above relies on) and the server settles a queue
3080
+ // FIFO, so the oldest are the only ones that can resolve next. Taking
3081
+ // the newest instead would wedge the batch — they cannot settle until
3082
+ // everything ahead of them has, and nothing ahead would hold a poll.
3083
+ var bgAllow: { [id: string]: boolean } = {};
3084
+ var bgHistBudget = MAX_CONCURRENT_BG_POLLS - self._countBgPolls();
3085
+ if (bgHistBudget > 0) {
3086
+ var bgIds = chatList.filter(function (it: any) {
3087
+ if (it.status !== 'running' && it.status !== 'pending') return false;
3088
+ if (!it.poll || !it.id) return false;
3089
+ if (!(it._isBgTask || it._isOnBgQueue)) return false;
3090
+ return !self.historyItemPolls.has(it.id);
3091
+ }).map(function (it: any) { return it.id as string; }).sort();
3092
+ for (var ba = 0; ba < bgIds.length && ba < bgHistBudget; ba++) bgAllow[bgIds[ba]] = true;
3093
+ }
1859
3094
  chatList.forEach(function (item: any) {
1860
3095
  if (item.status !== 'running' && item.status !== 'pending') return;
1861
3096
  if (!item.poll || !item.id) return;
@@ -1884,6 +3119,9 @@ export class ChatSession {
1884
3119
  // Background indexing polls are suppressed while paused; foreground
1885
3120
  // replies the user is waiting on are not.
1886
3121
  if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
3122
+ // Over the bg poll budget: leave this one unpolled. The drain picks
3123
+ // it up once an attached poll settles and frees a slot.
3124
+ if ((item._isBgTask || item._isOnBgQueue) && !bgAllow[item.id]) return;
1887
3125
  var capturedId = item.id;
1888
3126
  var pp = item.poll({
1889
3127
  latency: POLL_INTERVAL,
@@ -1939,6 +3177,11 @@ export class ChatSession {
1939
3177
  self.drainBgTaskQueue();
1940
3178
  }
1941
3179
 
3180
+ // Learn which files the queue is still working on. Only on a FIRST page:
3181
+ // this describes the whole queue, not the page, so paging older history
3182
+ // would re-ask for an answer that has not changed.
3183
+ if (!fetchMore) self.refreshLiveIndexState();
3184
+
1942
3185
  // Sticky, NOT forcing: this runs after every first-page load, including
1943
3186
  // the one resumePolling fires on visibilitychange. Forcing yanked a
1944
3187
  // reader who had scrolled up back to the bottom. On a genuine mount the
@@ -1965,7 +3208,7 @@ export class ChatSession {
1965
3208
  // Upload one attachment (a file = 1 member, a folder = N) to db storage and
1966
3209
  // queue indexing per member. The bytes I/O + chip rendering go through host
1967
3210
  // hooks; the overwrite/reindex flow, status lifecycle, and indexing live here.
1968
- uploadSingleAttachment(att: any): Promise<Array<{ name: string; url: string; storagePath: string }>> {
3211
+ uploadSingleAttachment(att: any, stageId?: string): Promise<Array<{ name: string; url: string; storagePath: string }>> {
1969
3212
  var self = this;
1970
3213
  var id = this.host.getIdentity();
1971
3214
  att.status = 'uploading'; att.progress = 0; att.errorMessage = '';
@@ -2007,7 +3250,16 @@ export class ChatSession {
2007
3250
  var isExists = code === 'EXISTS' || (msg && /exist/i.test(msg));
2008
3251
  if (!isExists) throw err; // a member upload failed → whole attachment fails (red)
2009
3252
  return self.host.promptOverwrite(member.file.name).then(function (choice) {
2010
- if (choice === 'overwrite') { existedBefore = true; return doMemberUpload(false); } // replace the existing file
3253
+ if (choice === 'overwrite') {
3254
+ existedBefore = true;
3255
+ // The bytes at this path are about to change while the
3256
+ // path itself does not, so the browser-cached mint for
3257
+ // it would keep handing back the old signed url and the
3258
+ // old cached body. This is the one place that knows,
3259
+ // so it marks the path for a refreshed mint.
3260
+ markImagePreviewStale(self.host.getIdentity().projectId || 'default', member.storagePath);
3261
+ return doMemberUpload(false); // replace the existing file
3262
+ }
2011
3263
  if (choice === 'skip') { skipped = true; return; } // leave it untouched; no upload/index
2012
3264
  hadExists = true; existedBefore = true; // keep it; Reindex
2013
3265
  });
@@ -2027,21 +3279,53 @@ export class ChatSession {
2027
3279
  // enqueued. Best-effort + optional-hook guarded: a missing record,
2028
3280
  // a permission error, or a host without the hook must not block
2029
3281
  // indexing.
2030
- var preIndex = (existedBefore && typeof self.host.deleteExistingFileRecord === 'function')
2031
- ? Promise.resolve(self.host.deleteExistingFileRecord(member.storagePath)).catch(function () { })
2032
- : Promise.resolve();
3282
+ // Then CREATE the file record before any pass is enqueued. Ordering matters: the
3283
+ // delete above wipes it (and cascades to its rows), so creating first would just
3284
+ // delete what we made. Every pass references "src::<path>", and a pass is a
3285
+ // separate model turn that cannot know whether an earlier one created it, so
3286
+ // guaranteeing it here is what stops the backend rejecting whole windows with
3287
+ // NOT_EXISTS on reference.unique_id.
3288
+ // Already being indexed: a second run would open a second collapsed row
3289
+ // for the same file and re-read the whole document. Decided BEFORE the
3290
+ // delete-then-repost, which would otherwise wipe the records the LIVE
3291
+ // run has already written and then not re-index them. Asked of the
3292
+ // QUEUE, not just this page — see isIndexRunLive.
3293
+ var alreadyIndexing = false;
3294
+ var preIndex = self.claimIndexRun(member.storagePath).then(function (claimed) {
3295
+ alreadyIndexing = !claimed;
3296
+ if (alreadyIndexing) {
3297
+ console.log('[chat-engine] skipping a duplicate index request for', member.storagePath);
3298
+ return;
3299
+ }
3300
+ if (existedBefore && typeof self.host.deleteExistingFileRecord === 'function') {
3301
+ return Promise.resolve(self.host.deleteExistingFileRecord(member.storagePath)).catch(function () { });
3302
+ }
3303
+ });
3304
+ preIndex = preIndex.then(function () {
3305
+ if (alreadyIndexing) return;
3306
+ if (typeof self.host.ensureFileIndexRecord !== 'function') return;
3307
+ return Promise.resolve(self.host.ensureFileIndexRecord(member.storagePath, {
3308
+ name: member.file.name,
3309
+ mime: mime || undefined,
3310
+ size: member.file.size,
3311
+ })).catch(function () { });
3312
+ });
2033
3313
  // Run a client-side attachment parser (e.g. .hwp) if one matches; its
2034
3314
  // output is inlined into the indexing request (falls back to office
2035
3315
  // extraction / web_fetch when no parser matches or it yields nothing).
2036
3316
  return preIndex.then(function () {
2037
3317
  return parseAttachmentContent(member.file, member.file.name, mime || undefined);
2038
3318
  }).then(function (parsedContent: string | null) {
2039
- return notifyAgentSaveAttachment({
3319
+ if (alreadyIndexing) return;
3320
+ // Tracked so a chat waiting on awaitIndexingDrained cannot be sent
3321
+ // in the window between this call and the queue accepting it.
3322
+ return self.trackIndexDispatch(notifyAgentSaveAttachment({
2040
3323
  platform: id.platform as 'claude' | 'openai',
2041
3324
  model: id.model,
2042
- service: id.serviceId,
3325
+ service: id.projectId,
3326
+ publicProjectId: id.publicProjectId,
2043
3327
  owner: id.owner,
2044
- userId: id.userId || id.serviceId,
3328
+ userId: id.userId || id.projectId,
2045
3329
  serviceName: id.serviceName,
2046
3330
  serviceDescription: id.serviceDescription,
2047
3331
  attachment: {
@@ -2052,7 +3336,7 @@ export class ChatSession {
2052
3336
  }).then(function (ack: any) {
2053
3337
  if (ack && typeof ack.id === 'string') {
2054
3338
  self.bgTaskQueue.push({
2055
- serviceId: id.serviceId, platform: id.platform as 'claude' | 'openai', id: ack.id,
3339
+ projectId: id.projectId, platform: id.platform as 'claude' | 'openai', id: ack.id,
2056
3340
  filename: member.file.name,
2057
3341
  storagePath: member.storagePath,
2058
3342
  isReindex: hadExists,
@@ -2060,18 +3344,26 @@ export class ChatSession {
2060
3344
  size: member.file.size,
2061
3345
  status: ack.status === 'running' ? 'running' : 'pending',
2062
3346
  poll: ack.poll,
3347
+ // Puts this file's row directly above the chat turn it was
3348
+ // attached to (drainBgTaskQueue). Undefined for an
3349
+ // attachment-only send, which appends.
3350
+ stageId: stageId,
2063
3351
  });
2064
3352
  self.drainBgTaskQueue(); // surface "Indexing: <file>" as soon as THIS file uploads
2065
3353
  }
2066
3354
  }, function (e: any) {
2067
3355
  console.error('[chat-engine] indexing request failed', e);
3356
+ // Nothing was queued, so hand the slot back: the retry (a later
3357
+ // send of the same chip) must not be refused by this client's own
3358
+ // claim. See claimIndexRun.
3359
+ self.releaseIndexRun(member.storagePath);
2068
3360
  anyIndexFailed = true; // uploaded but not indexed → yellow
2069
3361
  // Record the first index error's code/message for the report dialog.
2070
3362
  if (!att.errorCode && !att.errorDetail) {
2071
3363
  att.errorCode = (e && (e.code || (e.body && e.body.code))) || '';
2072
3364
  att.errorDetail = (e && (e.message || (e.body && e.body.message))) || (typeof e === 'string' ? e : '');
2073
3365
  }
2074
- });
3366
+ }));
2075
3367
  });
2076
3368
  });
2077
3369
  });
@@ -2088,14 +3380,28 @@ export class ChatSession {
2088
3380
 
2089
3381
  // Upload all not-yet-done attachments sequentially. Resolves to the full
2090
3382
  // list of { name, url, storagePath } for composing the chat message.
2091
- uploadPendingAttachments(): Promise<Array<{ name: string; url: string; storagePath?: string }>> {
3383
+ //
3384
+ // `batchId` scopes the run to the chips stamped with it at Send time. The
3385
+ // composer stays live during an upload, so by the time this runs the
3386
+ // attachment list can already hold chips the user picked for the NEXT
3387
+ // message — uploading those here would attach them to the wrong turn, and
3388
+ // collecting the previous batch's finished urls would attach files the user
3389
+ // already sent. Omitted (no batch) means every chip, the old behavior.
3390
+ //
3391
+ // `stageId` is the turn these chips were attached to, carried onto every indexing
3392
+ // task so its collapsed row renders directly ABOVE that turn's bubble (see
3393
+ // BgTaskEntry.stageId). Omitted for an attachment-only send, which has no turn.
3394
+ uploadPendingAttachments(batchId?: string, stageId?: string): Promise<Array<{ name: string; url: string; storagePath?: string }>> {
2092
3395
  var self = this;
2093
3396
  this.host.resetOverwriteBatch();
3397
+ this._uploadBatches += 1;
2094
3398
  this.state.uploadingAttachments = true;
2095
3399
  this.host.updateComposerControls();
2096
3400
  this.host.renderAttachmentChips();
2097
3401
  var collected: Array<{ name: string; url: string; storagePath?: string }> = [];
2098
- var snapshot = this.state.attachments.slice();
3402
+ var snapshot = this.state.attachments.filter(function (a: any) {
3403
+ return batchId ? a._batchId === batchId : true;
3404
+ });
2099
3405
  var chain: Promise<any> = Promise.resolve();
2100
3406
  snapshot.forEach(function (att: any) {
2101
3407
  chain = chain.then(function () {
@@ -2107,7 +3413,7 @@ export class ChatSession {
2107
3413
  }
2108
3414
  if (att.uploadedUrl) { collected.push({ name: att.name, url: att.uploadedUrl, storagePath: att.storagePath }); return; }
2109
3415
  }
2110
- return self.uploadSingleAttachment(att).then(function (us) {
3416
+ return self.uploadSingleAttachment(att, stageId).then(function (us) {
2111
3417
  collected.push.apply(collected, us);
2112
3418
  }).catch(function (err: any) {
2113
3419
  var removed = !self.state.attachments.some(function (a: any) { return a.id === att.id; });
@@ -2123,7 +3429,11 @@ export class ChatSession {
2123
3429
  });
2124
3430
  });
2125
3431
  var done = function () {
2126
- self.state.uploadingAttachments = false; self.host.updateComposerControls(); self.host.renderAttachmentChips();
3432
+ self._uploadBatches = Math.max(0, self._uploadBatches - 1);
3433
+ // Only the LAST batch standing clears the flag — a second send made
3434
+ // while this one was uploading is still running.
3435
+ self.state.uploadingAttachments = self._uploadBatches > 0;
3436
+ self.host.updateComposerControls(); self.host.renderAttachmentChips();
2127
3437
  return collected;
2128
3438
  };
2129
3439
  return chain.then(done, done);