bunnyquery 1.8.3 → 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,11 +23,13 @@ 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,
32
+ MAX_CONCURRENT_BG_POLLS,
31
33
  bgIndexingQueueName,
32
34
  isBgIndexingQueue,
33
35
  ANTHROPIC_MESSAGES_API_URL,
@@ -39,6 +41,7 @@ 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';
@@ -53,6 +56,17 @@ 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
@@ -82,6 +96,22 @@ const INDEXING_DRAIN_MIN_MS = 8000;
82
96
  // against a partly-indexed file is a poor outcome, but a question that is never
83
97
  // asked at all because one chain wedged server-side is a worse one.
84
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;
85
115
 
86
116
  // requestAnimationFrame / high-res clock, reached through globalThis so the
87
117
  // engine stays DOM-free at the type level (and degrades gracefully in non-DOM
@@ -142,6 +172,38 @@ export class ChatSession {
142
172
  * would read the gap between "pass N settled" and "pass N+1 accepted" as the
143
173
  * file being finished. */
144
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 };
145
207
 
146
208
  constructor(host: ChatHost) {
147
209
  this.host = host;
@@ -158,6 +220,9 @@ export class ChatSession {
158
220
  historyStartKeyHistory: [],
159
221
  historyRequestToken: 0,
160
222
  gateRefreshToken: 0,
223
+ liveIndexKeys: {},
224
+ liveIndexChecked: false,
225
+ stoppedIndexIds: {},
161
226
  };
162
227
  this.bgTaskQueue = [];
163
228
  this.cancelledServerIds = new Set();
@@ -171,6 +236,295 @@ export class ChatSession {
171
236
  this._stageSeq = 0;
172
237
  this._uploadBatches = 0;
173
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();
174
528
  }
175
529
 
176
530
  /** Wrap an indexing-request dispatch so awaitIndexingDrained counts it as
@@ -178,10 +532,43 @@ export class ChatSession {
178
532
  trackIndexDispatch<T>(p: Promise<T>): Promise<T> {
179
533
  var self = this;
180
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.
181
545
  var release = function () { self._indexDispatchesInFlight = Math.max(0, self._indexDispatchesInFlight - 1); };
182
546
  return p.then(function (v) { release(); return v; }, function (e) { release(); throw e; });
183
547
  }
184
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
+ }
570
+ }
571
+
185
572
  /**
186
573
  * Register a live poll so (a) a remount dedupes against it instead of stacking a
187
574
  * SECOND poll on the same item, and (b) pausePolling can stop it.
@@ -200,6 +587,19 @@ export class ChatSession {
200
587
  return p;
201
588
  }
202
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
+
203
603
  /**
204
604
  * Stop and forget one item's poll. Used after a cancel: the row is either gone
205
605
  * (cancelled while queued) or flagged cancelled (cancelled while running), so
@@ -284,8 +684,8 @@ export class ChatSession {
284
684
 
285
685
  getHistoryCacheKey(): string {
286
686
  var id = this.host.getIdentity();
287
- if (!id.serviceId || id.platform === 'none') return '';
288
- return id.serviceId + '#' + id.platform;
687
+ if (!id.projectId || id.platform === 'none') return '';
688
+ return id.projectId + '#' + id.platform;
289
689
  }
290
690
 
291
691
  updateHistoryCache(): void {
@@ -300,14 +700,22 @@ export class ChatSession {
300
700
  // history, bg tasks) are always kept. Single pass: this runs on the
301
701
  // typewriter hot path.
302
702
  //
303
- // Staged bubbles (_stageId) are never cached. What resolves one is an
304
- // in-page upload; a reload kills that upload but not the cache, so a cached
305
- // staged bubble would come back as a message that uploads forever. Dropping
306
- // it costs nothing while the page lives — a history refetch re-rescues it
307
- // from state.messages, and a dispatch that no longer finds it just appends.
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.
308
717
  this.aiChatHistoryCache[key] = {
309
718
  messages: this.state.messages.filter(function (m) {
310
- if (m._stageId) return false;
311
719
  return m._ownerKey === undefined || m._ownerKey === key;
312
720
  }),
313
721
  endOfList: this.state.historyEndOfList,
@@ -361,6 +769,11 @@ export class ChatSession {
361
769
  for (var j = 0; j < msgs.length; j++) {
362
770
  var u = msgs[j];
363
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;
364
777
  if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
365
778
  if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
366
779
  var settled: ChatMessage = { role: 'user', content: u.content };
@@ -378,21 +791,21 @@ export class ChatSession {
378
791
  }
379
792
 
380
793
  /**
381
- * 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
382
795
  * dispatched after the user moved to another project, and re-reading the live
383
796
  * identity here would silently send the turn to THAT project instead of the
384
797
  * one it was composed for. Falls back to the live read only when a caller
385
798
  * omits them.
386
799
  */
387
- private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any, serviceId?: string, owner?: string) {
388
- 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) {
389
802
  var id = this.host.getIdentity();
390
- if (serviceId === undefined) serviceId = id.serviceId;
803
+ if (projectId === undefined) projectId = id.projectId;
391
804
  if (owner === undefined) owner = id.owner;
392
805
  }
393
806
  return platform === 'openai'
394
- ? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls)
395
- : 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);
396
809
  }
397
810
 
398
811
  dispatchAgentRequest(params: any) {
@@ -406,7 +819,7 @@ export class ChatSession {
406
819
  var dispatchItemId: string | undefined;
407
820
  var sendAndPoll = function () {
408
821
  return Promise.resolve(
409
- 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)
410
823
  ).then(function (initial: any) {
411
824
  if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
412
825
  if (initial.id) {
@@ -508,14 +921,57 @@ export class ChatSession {
508
921
  var staged: ChatMessage = {
509
922
  role: 'user', content: displayText,
510
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(),
511
931
  _useBgQueue: true, _stageId: stageId, _ts: wallClockNow(),
512
932
  };
513
933
  if (key) staged._ownerKey = key;
934
+ this._liveStages[stageId] = true;
514
935
  this.state.messages.push(staged);
515
936
  this.host.notify(); this.host.scrollToBottom(true);
516
937
  return stageId;
517
938
  }
518
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
+
519
975
  private _stageIndex(list: ChatMessage[], stageId?: string): number {
520
976
  if (!stageId) return -1;
521
977
  for (var i = 0; i < list.length; i++) {
@@ -525,34 +981,44 @@ export class ChatSession {
525
981
  }
526
982
 
527
983
  /**
528
- * Where a staged turn belongs once it is finally sent: BELOW every indexing
529
- * row, because that is the order it ran in and the order the server history
530
- * will report on the next load (its request id is newer than every pass it
531
- * waited for). While its files were uploading it sat above them — the rows
532
- * are injected as each file's pass starts, after the bubble was staged.
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.
533
988
  *
534
- * Returns the index to insert at, or -1 to leave the turn where it is. Never
535
- * moves a turn UP: a bubble that already sits below the indexing rows (or a
536
- * chat with no indexing at all) must not jump backwards over anything.
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.
537
992
  */
538
- private _settledStagePosition(fromIdx: number): number {
539
- var lastBg = -1;
540
- for (var i = 0; i < this.state.messages.length; i++) {
541
- if (this.state.messages[i] && this.state.messages[i].isBackgroundTask) lastBg = i;
542
- }
543
- if (lastBg <= fromIdx) return -1;
544
- // Splicing the staged bubble out first shifts everything after it down one.
545
- return lastBg;
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();
546
1002
  }
547
1003
 
548
- /** The staged turn's files are up; it is now just waiting its place in the
549
- * queue. Drops the "(Uploading files...)" note back to "(In queue)". */
550
- markStagedMessageQueued(stageId: string): void {
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 {
551
1015
  var idx = this._stageIndex(this.state.messages, stageId);
552
1016
  if (idx === -1) return;
553
1017
  var ex = this.state.messages[idx];
554
- if (!ex.isUploadingAttachments) return;
555
- this.state.messages[idx] = Object.assign({}, ex, { isUploadingAttachments: false });
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
+ });
556
1022
  this.host.notify();
557
1023
  }
558
1024
 
@@ -580,7 +1046,7 @@ export class ChatSession {
580
1046
  */
581
1047
  awaitIndexingDrained(identity: ChatIdentity): Promise<'drained' | 'timedout' | 'skipped'> {
582
1048
  var self = this;
583
- var svcId = identity && identity.serviceId;
1049
+ var svcId = identity && identity.projectId;
584
1050
  var platform = identity && identity.platform;
585
1051
  if (!svcId || (platform !== 'claude' && platform !== 'openai')) return Promise.resolve('skipped' as const);
586
1052
  var owner = identity.owner;
@@ -588,11 +1054,26 @@ export class ChatSession {
588
1054
  var startedAt = nowMs();
589
1055
  var deadline = startedAt + INDEXING_DRAIN_TIMEOUT_MS;
590
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.
591
1061
  var ask = function (status: 'pending' | 'running') {
592
- return Promise.resolve(getChatHistory(
593
- { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
594
- { limit: WORKER_PASS_ADOPT_LIMIT },
595
- )).catch(function () { return null; });
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
+ });
596
1077
  };
597
1078
  // Ordinary chats are routed onto this queue too (_isOnBgQueue), and those
598
1079
  // are not work this turn has to wait behind — the server runs the queue in
@@ -607,24 +1088,68 @@ export class ChatSession {
607
1088
  return false;
608
1089
  };
609
1090
  return new Promise(function (resolve) {
610
- var again = function () {
611
- setTimeout(look, idleLooks > 0 ? INDEXING_DRAIN_CONFIRM_POLL_MS : INDEXING_DRAIN_BUSY_POLL_MS);
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)));
612
1130
  };
613
1131
  var look = function () {
614
- if (nowMs() >= deadline) { resolve('timedout'); return; }
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; }
615
1137
  // A pass whose ack is still in flight is not on the queue yet, so no
616
1138
  // look can see it.
617
1139
  if (self._indexDispatchesInFlight > 0) { idleLooks = 0; again(); return; }
1140
+ inFlight = true;
618
1141
  Promise.all([ask('running'), ask('pending')]).then(function (res: any[]) {
1142
+ inFlight = false;
619
1143
  var unknown = res[0] === null || res[1] === null;
620
1144
  if (unknown || hasLiveIndexing(res[0]) || hasLiveIndexing(res[1])) idleLooks = 0;
621
1145
  else idleLooks += 1;
622
1146
  if (idleLooks >= INDEXING_DRAIN_IDLE_LOOKS && nowMs() - startedAt >= INDEXING_DRAIN_MIN_MS) {
623
- resolve('drained'); return;
1147
+ finish('drained'); return;
624
1148
  }
625
1149
  again();
626
- }, function () { idleLooks = 0; again(); });
1150
+ }, function () { inFlight = false; idleLooks = 0; again(); });
627
1151
  };
1152
+ self._drainNudges.push(nudge);
628
1153
  look();
629
1154
  });
630
1155
  }
@@ -636,12 +1161,17 @@ export class ChatSession {
636
1161
  * failure separately.
637
1162
  */
638
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];
639
1167
  var idx = this._stageIndex(this.state.messages, stageId);
640
1168
  if (idx === -1) return;
641
1169
  var ex = this.state.messages[idx];
642
1170
  var settled: ChatMessage = { role: 'user', content: ex.content };
643
1171
  if (ex._ownerKey !== undefined) settled._ownerKey = ex._ownerKey;
644
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;
645
1175
  this.state.messages[idx] = settled;
646
1176
  this.host.notify(); this.updateHistoryCache();
647
1177
  }
@@ -665,6 +1195,9 @@ export class ChatSession {
665
1195
  // caller doesn't pin (the widget, which has only one project anyway).
666
1196
  var id = pinned ? pinned.identity : this.host.getIdentity();
667
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];
668
1201
 
669
1202
  var llmComposed = composedForLlm || composed;
670
1203
 
@@ -672,8 +1205,8 @@ export class ChatSession {
672
1205
  // bubble is stamped with it so a project switch (which flips
673
1206
  // getIdentity()/getHistoryCacheKey() to the new project) can't
674
1207
  // misattribute this turn's bubbles to that project.
675
- // (platform === 'none' already returned above, so serviceId is the only gate)
676
- 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;
677
1210
  // True when the pinned chat is NOT the one currently on screen. Then
678
1211
  // state.messages belongs to a different project and MUST NOT be touched:
679
1212
  // the turn is staged in the pinned chat's cache instead and shows up when
@@ -687,7 +1220,7 @@ export class ChatSession {
687
1220
  var aiPlatform = id.platform;
688
1221
  var aiModel = id.model || undefined;
689
1222
  var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
690
- var userId = id.userId || id.serviceId;
1223
+ var userId = id.userId || id.projectId;
691
1224
  // Same string the indexing passes are enqueued under (bgIndexingQueueName),
692
1225
  // which is the whole reason this turn ends up behind them: the backend runs
693
1226
  // different queue names in parallel and only serialises a shared one.
@@ -707,7 +1240,7 @@ export class ChatSession {
707
1240
  !m.isCancelled && !m.isBackgroundTask && !m.isError;
708
1241
  });
709
1242
  var offBounded = buildBoundedChatMessages({
710
- platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
1243
+ platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
711
1244
  history: offHistory.concat([{ role: 'user', content: llmComposed }]),
712
1245
  });
713
1246
  var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
@@ -723,8 +1256,22 @@ export class ChatSession {
723
1256
  this.state.messages.splice(offStage, 1);
724
1257
  this.host.notify();
725
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
+ }
726
1273
  this.aiChatHistoryCache[key] = {
727
- messages: offExisting.messages.concat([
1274
+ messages: offCached.concat([
728
1275
  offUser,
729
1276
  { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
730
1277
  ]),
@@ -732,7 +1279,7 @@ export class ChatSession {
732
1279
  startKeyHistory: offExisting.startKeyHistory,
733
1280
  };
734
1281
  this.dispatchAgentRequest({
735
- 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,
736
1283
  systemPrompt: systemPrompt, text: composed, boundedMessages: offBounded.messages, userId: chatQueue,
737
1284
  extractContent: extractContent, fileUrls: fileUrls,
738
1285
  });
@@ -745,30 +1292,38 @@ export class ChatSession {
745
1292
  !m.isCancelled && !m.isBackgroundTask && !m.isError;
746
1293
  });
747
1294
  var boundedQ = buildBoundedChatMessages({
748
- platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
1295
+ platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
749
1296
  history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
750
1297
  });
751
- 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() };
752
1302
  if (key) queuedBubble._ownerKey = key;
753
1303
  if (useBgQueue) queuedBubble._useBgQueue = true;
754
1304
  var qStage = this._stageIndex(this.state.messages, stageId);
755
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];
756
1312
  // Keep the send time the user saw, not the upload's finish time.
757
- if (this.state.messages[qStage]._ts !== undefined) queuedBubble._ts = this.state.messages[qStage]._ts;
758
- var qTarget = this._settledStagePosition(qStage);
759
- if (qTarget === -1) {
760
- this.state.messages.splice(qStage, 1, queuedBubble);
761
- } else {
762
- this.state.messages.splice(qStage, 1);
763
- this.state.messages.splice(qTarget, 0, queuedBubble);
764
- }
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);
765
1320
  } else {
766
1321
  this.state.messages.push(queuedBubble);
767
1322
  }
768
1323
  this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
769
1324
 
770
1325
  var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
771
- 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))
772
1327
  .then(function (result: any) {
773
1328
  // Only ack a bubble that belongs to THIS chat — the search is
774
1329
  // positional, so on another project it would stamp this turn's
@@ -783,7 +1338,7 @@ export class ChatSession {
783
1338
  });
784
1339
  var serverId = result && typeof result.id === 'string' ? result.id : undefined;
785
1340
  if (sendingIdx >= 0) {
786
- var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false });
1341
+ var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false, _dimSending: false });
787
1342
  if (serverId) upd._serverItemId = serverId;
788
1343
  self.state.messages[sendingIdx] = upd; self.host.notify();
789
1344
  }
@@ -807,19 +1362,22 @@ export class ChatSession {
807
1362
  // view unmount), then rendered from the cache via typewriteLatestReply. A
808
1363
  // later resumePendingRequest() re-renders it if the view remounted while the
809
1364
  // request was still in flight.
810
- var immediateUser: ChatMessage = { role: 'user', content: composed, _ts: wallClockNow(), ...(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 } : {}) };
811
1370
  var immediatePlaceholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) };
812
1371
  var iStage = this._stageIndex(this.state.messages, stageId);
813
1372
  if (iStage !== -1) {
1373
+ // Replace IN PLACE — see the queued branch above for why nothing moves.
1374
+ var iEx = this.state.messages[iStage];
814
1375
  // Keep the send time the user saw, not the upload's finish time.
815
- if (this.state.messages[iStage]._ts !== undefined) immediateUser._ts = this.state.messages[iStage]._ts;
816
- var iTarget = this._settledStagePosition(iStage);
817
- if (iTarget === -1) {
818
- this.state.messages.splice(iStage, 1, immediateUser, immediatePlaceholder);
819
- } else {
820
- this.state.messages.splice(iStage, 1);
821
- this.state.messages.splice(iTarget, 0, immediateUser, immediatePlaceholder);
822
- }
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);
823
1381
  } else {
824
1382
  this.state.messages.push(immediateUser);
825
1383
  this.state.messages.push(immediatePlaceholder);
@@ -858,11 +1416,11 @@ export class ChatSession {
858
1416
  });
859
1417
  historyForLlm.push({ role: 'user', content: llmComposed });
860
1418
  var bounded = buildBoundedChatMessages({
861
- platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
1419
+ platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
862
1420
  history: historyForLlm,
863
1421
  });
864
1422
  var run = this.dispatchAgentRequest({
865
- 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,
866
1424
  systemPrompt: systemPrompt, text: composed, boundedMessages: bounded.messages, userId: chatQueue,
867
1425
  extractContent: extractContent, fileUrls: fileUrls,
868
1426
  });
@@ -925,6 +1483,14 @@ export class ChatSession {
925
1483
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
926
1484
  if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
927
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;
928
1494
  this.state.messages[nextIdx] = promoted;
929
1495
  // Carry the promoted turn's _serverItemId onto the "Thinking..." placeholder
930
1496
  // (mirrors promoteNextBgQueuedToRunning). Without it, when this promoted turn
@@ -941,6 +1507,36 @@ export class ChatSession {
941
1507
  this.host.notify();
942
1508
  }
943
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
+
944
1540
  resolveQueuedUserBubble(serverId?: string): number | undefined {
945
1541
  // The two fallbacks below match by POSITION, not identity, so they must
946
1542
  // never consider a bubble stamped for a different chat.
@@ -968,9 +1564,7 @@ export class ChatSession {
968
1564
  if (userIdx >= 0) {
969
1565
  var ex = this.state.messages[userIdx];
970
1566
  this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...(ex._ownerKey !== undefined ? { _ownerKey: ex._ownerKey } : {}) };
971
- var thIdx = this.state.messages.findIndex(function (m, i) {
972
- return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
973
- });
1567
+ var thIdx = this._ownThinkingIndex(userIdx, serverId);
974
1568
  if (thIdx !== -1) this.state.messages.splice(thIdx, 1);
975
1569
  }
976
1570
  this.promoteNextQueuedToRunning();
@@ -982,11 +1576,12 @@ export class ChatSession {
982
1576
  if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
983
1577
  if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
984
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;
985
1582
  this.state.messages[userIdx] = repl;
986
1583
  }
987
- var thinkingIdx = userIdx >= 0
988
- ? this.state.messages.findIndex(function (m, i) { return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask; })
989
- : -1;
1584
+ var thinkingIdx = this._ownThinkingIndex(userIdx, serverId);
990
1585
  return thinkingIdx !== -1 ? thinkingIdx : (userIdx >= 0 ? userIdx + 1 : -1);
991
1586
  }
992
1587
 
@@ -994,7 +1589,17 @@ export class ChatSession {
994
1589
  // Error/direct replies land here rather than through the typewriter, so this
995
1590
  // is where they pick up their display time.
996
1591
  if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = wallClockNow();
997
- 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;
998
1603
  else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
999
1604
  else this.state.messages.push(msg);
1000
1605
  }
@@ -1101,12 +1706,30 @@ export class ChatSession {
1101
1706
  var platform = id.platform;
1102
1707
  if (platform !== 'claude' && platform !== 'openai') return;
1103
1708
  var url = platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
1104
- var queueBase = id.userId || id.serviceId;
1709
+ var queueBase = id.userId || id.projectId;
1105
1710
  var queue = (msg.isBackgroundTask || msg._useBgQueue) ? bgIndexingQueueName(queueBase) : queueBase;
1106
- this.state.messages[idx] = Object.assign({}, msg, { _cancelling: true, _cancelError: undefined });
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
+ }
1107
1730
  this.host.notify();
1108
1731
  Promise.resolve(this.host.cancelRequest({
1109
- 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,
1110
1733
  })).then(function (result: any) {
1111
1734
  if (result && result.removed) {
1112
1735
  self.cancelledServerIds.add(serverId as string);
@@ -1167,7 +1790,10 @@ export class ChatSession {
1167
1790
  * 2. the file is remembered in cancelledIndexKeys, so the client-driven
1168
1791
  * resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
1169
1792
  * 3. any of its passes still sitting in bgTaskQueue is dropped by the next
1170
- * 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.
1171
1797
  *
1172
1798
  * Records already written by the passes that DID run are kept — this stops the
1173
1799
  * work, it does not undo it.
@@ -1179,6 +1805,51 @@ export class ChatSession {
1179
1805
  // _indexKeyOf scopes a queued task's.
1180
1806
  var scoped = this.getHistoryCacheKey() + '|' + group.key;
1181
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);
1182
1853
  var ids = group.cancellableIds || [];
1183
1854
  if (!ids.length) { this.host.notify(); return; }
1184
1855
  ids.forEach(function (serverId) {
@@ -1352,23 +2023,77 @@ export class ChatSession {
1352
2023
  return this.enqueueTypewrite(pendingIdx, latest.content, lid);
1353
2024
  }
1354
2025
 
1355
- // Remove any leftover non-background pending ("Thinking…") assistant bubbles.
1356
- // There is normally at most ONE such bubble at a time (promoteNext* refuses to
1357
- // add a second), so any extra is a duplicate — it appears when a concurrent
1358
- // history refetch re-maps the still-"running" turn into a pending placeholder
1359
- // (with a real _serverItemId) while the local pending bubble (no _serverItemId)
1360
- // is rescued and re-appended (see loadHistory rescue below). Each resolve path
1361
- // only replaces the FIRST pending bubble, so without this a stray "Thinking…"
1362
- // survives next to the reply/error. MUST run AFTER the resolved bubble has been
1363
- // made non-pending and BEFORE promoteNext*() (so a freshly-promoted Thinking,
1364
- // 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.
1365
2050
  _removeStrayPendingAssistants(): void {
1366
2051
  for (var k = this.state.messages.length - 1; k >= 0; k--) {
1367
2052
  var m = this.state.messages[k];
1368
- 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);
1369
2056
  }
1370
2057
  }
1371
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
+
1372
2097
  // Drop the pending flags on the resolved turn's USER bubble (preserving its
1373
2098
  // content + background-task marker). Needed because a bg "Indexing:" turn's user
1374
2099
  // bubble carries isPendingInProcess; leaving it set keeps the bubble visually
@@ -1420,6 +2145,11 @@ export class ChatSession {
1420
2145
  var indexRef = this._indexRefOfItem(itemId);
1421
2146
  this.applyHistoryItemResolution(itemId, response, platform);
1422
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();
1423
2153
  // A worker-driven chain has no client-side record of its next pass, so a
1424
2154
  // settling pass is the only moment there is to go looking for one.
1425
2155
  if (indexRef) this._followWorkerIndexingChain(indexRef.name, indexRef.mime);
@@ -1437,13 +2167,85 @@ export class ChatSession {
1437
2167
  return null;
1438
2168
  }
1439
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
+
1440
2225
  applyHistoryItemResolution(itemId: string, response: any, platform: string): void {
1441
2226
  this.historyItemPolls.delete(itemId);
2227
+ if (this._isCancelledPollResult(response)) {
2228
+ this._settleCancelledItem(itemId);
2229
+ return;
2230
+ }
1442
2231
  var isErr = isErrorResponseBody(response);
1443
2232
  var answer = isErr ? getErrorMessage(response)
1444
2233
  : ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim();
1445
- // Hide the internal indexing-completion marker from the displayed summary.
1446
- 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
+ };
1447
2249
  var idx = this.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === itemId; });
1448
2250
  if (idx !== -1) {
1449
2251
  // A bg "Indexing:" turn pushes a user bubble (isPendingInProcess) ALONGSIDE
@@ -1474,7 +2276,7 @@ export class ChatSession {
1474
2276
  // history, and ran a per-frame scroll for content nobody can see.
1475
2277
  // Write it straight in; expanding the row then shows it complete.
1476
2278
  if (wasBgTask) {
1477
- 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 } : {}) };
1478
2280
  this.host.notify(); this.updateHistoryCache(); return;
1479
2281
  }
1480
2282
  var lid = this._newLocalId();
@@ -1506,7 +2308,7 @@ export class ChatSession {
1506
2308
  // Same as above: a collapsed row's reply is not on screen, so revealing it
1507
2309
  // character by character only blocks the queue the visible reply needs.
1508
2310
  if (ex.isBackgroundTask) {
1509
- 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 } : {}) });
1510
2312
  this.host.notify(); this.updateHistoryCache(); return;
1511
2313
  }
1512
2314
  var lid2 = this._newLocalId();
@@ -1525,7 +2327,7 @@ export class ChatSession {
1525
2327
  if (!entry) return '';
1526
2328
  var file = entry.storagePath || entry.filename;
1527
2329
  if (!file) return '';
1528
- return entry.serviceId + '#' + entry.platform + '|' + file;
2330
+ return entry.projectId + '#' + entry.platform + '|' + file;
1529
2331
  }
1530
2332
 
1531
2333
  /**
@@ -1536,14 +2338,38 @@ export class ChatSession {
1536
2338
  * path, and without this an earlier cancel would silently kill every future
1537
2339
  * index of the same path. A continuation of a stopped file is dropped instead,
1538
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.
1539
2351
  */
1540
2352
  private _applyIndexCancellations(): void {
1541
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
+ });
1542
2364
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
1543
2365
  var entry = this.bgTaskQueue[i];
1544
2366
  var key = this._indexKeyOf(entry);
1545
2367
  if (!key || !this.cancelledIndexKeys.has(key)) continue;
1546
- 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;
1547
2373
  this.bgTaskQueue.splice(i, 1);
1548
2374
  this._stopPoll(entry.id);
1549
2375
  this._cancelServerItem(entry.id);
@@ -1630,10 +2456,10 @@ export class ChatSession {
1630
2456
  if (this._adoptingWorkerPasses) return;
1631
2457
  var id = this.host.getIdentity();
1632
2458
  var platform = id.platform;
1633
- if (!id.serviceId || (platform !== 'claude' && platform !== 'openai')) return;
2459
+ if (!id.projectId || (platform !== 'claude' && platform !== 'openai')) return;
1634
2460
  if (this.isPollingPaused() || !this.host.isViewMounted()) return;
1635
- var svcId = id.serviceId, owner = id.owner;
1636
- var queue = bgIndexingQueueName(id.userId, id.serviceId);
2461
+ var svcId = id.projectId, owner = id.owner;
2462
+ var queue = bgIndexingQueueName(id.userId, id.projectId);
1637
2463
  var ask = function (status: 'pending' | 'running') {
1638
2464
  return Promise.resolve(getChatHistory(
1639
2465
  { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
@@ -1646,8 +2472,16 @@ export class ChatSession {
1646
2472
  // The chat may have changed under the query; adopting into another
1647
2473
  // project's session is the cross-project bubble leak all over again.
1648
2474
  var now = self.host.getIdentity();
1649
- if (now.serviceId !== svcId || now.platform !== platform) return;
2475
+ if (now.projectId !== svcId || now.platform !== platform) return;
1650
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);
1651
2485
  var adoptedIds: string[] = [];
1652
2486
  for (var ri = 0; ri < results.length; ri++) {
1653
2487
  var list = results[ri] && Array.isArray(results[ri].list) ? results[ri].list : [];
@@ -1671,10 +2505,16 @@ export class ChatSession {
1671
2505
  // empty queue for a chain that is very much alive — and with no pass
1672
2506
  // left to settle, nothing would ever ask again. Look once or twice more
1673
2507
  // before believing the file is finished.
1674
- 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
+ }
1675
2515
  setTimeout(function () {
1676
2516
  var later = self.host.getIdentity();
1677
- if (later.serviceId !== svcId || later.platform !== platform) return;
2517
+ if (later.projectId !== svcId || later.platform !== platform) return;
1678
2518
  if (self.isPollingPaused() || !self.host.isViewMounted()) return;
1679
2519
  self._adoptWorkerIndexingPasses(attempt + 1);
1680
2520
  }, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
@@ -1729,7 +2569,7 @@ export class ChatSession {
1729
2569
  // chain's passes — which is the cap that stops it running forever.
1730
2570
  if (!this._isWorkerDrivenIndexing(ref.name, ref.mime)) return false;
1731
2571
  this.bgTaskQueue.push({
1732
- serviceId: svcId,
2572
+ projectId: svcId,
1733
2573
  platform: platform,
1734
2574
  id: item.id,
1735
2575
  filename: ref.name,
@@ -1760,8 +2600,8 @@ export class ChatSession {
1760
2600
  var url = id.platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
1761
2601
  Promise.resolve(this.host.cancelRequest({
1762
2602
  url: url, method: 'POST', id: serverId,
1763
- queue: bgIndexingQueueName(id.userId, id.serviceId),
1764
- service: id.serviceId, owner: id.owner,
2603
+ queue: bgIndexingQueueName(id.userId, id.projectId),
2604
+ service: id.projectId, owner: id.owner,
1765
2605
  })).catch(function () { /* the pass may already have finished; nothing to do */ });
1766
2606
  }
1767
2607
 
@@ -1769,7 +2609,7 @@ export class ChatSession {
1769
2609
  drainBgTaskQueue(): void {
1770
2610
  var self = this;
1771
2611
  var id = this.host.getIdentity();
1772
- var svcId = id.serviceId, plat = id.platform;
2612
+ var svcId = id.projectId, plat = id.platform;
1773
2613
  if (!svcId || plat === 'none' || !this.host.isViewMounted()) return;
1774
2614
  // Before anything is surfaced: drop continuations of files the user stopped
1775
2615
  // (and let a fresh first pass lift the stop), then cancel any worker-queued
@@ -1790,11 +2630,20 @@ export class ChatSession {
1790
2630
  });
1791
2631
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
1792
2632
  var e = this.bgTaskQueue[i];
1793
- if (e.serviceId !== svcId || e.platform !== plat) continue;
2633
+ if (e.projectId !== svcId || e.platform !== plat) continue;
1794
2634
  if (presentIds[e.id] && !pendingIds[e.id]) this.bgTaskQueue.splice(i, 1);
1795
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;
1796
2645
  this.bgTaskQueue.forEach(function (entry) {
1797
- if (entry.serviceId !== svcId || entry.platform !== plat) return;
2646
+ if (entry.projectId !== svcId || entry.platform !== plat) return;
1798
2647
  // Bubble injection and poll attachment are INDEPENDENT. An entry whose bubble
1799
2648
  // already exists may still need a poll — that is exactly the state a paused
1800
2649
  // drain leaves behind, and returning early here stranded it as a permanent
@@ -1818,14 +2667,50 @@ export class ChatSession {
1818
2667
  },
1819
2668
  };
1820
2669
  if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
1821
- self.state.messages.push(userBubble);
1822
- if (isRunning) {
1823
- 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);
1824
2700
  }
1825
2701
  presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
1826
- 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;
1827
2711
  }
1828
- 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--;
1829
2714
  var capturedId = entry.id, capturedPlat = plat;
1830
2715
  var capturedEntry = entry;
1831
2716
  var wasStopped = false;
@@ -1862,15 +2747,39 @@ export class ChatSession {
1862
2747
  }
1863
2748
  }
1864
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
+ }
1865
2760
  }).then(function () {
1866
2761
  // Keep the queue entry when the poll was merely stopped, or resuming
1867
2762
  // would have nothing left to re-attach to.
1868
2763
  if (wasStopped) return;
1869
2764
  var qi = self.bgTaskQueue.findIndex(function (q) { return q.id === capturedId; });
1870
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();
1871
2773
  });
1872
2774
  }
1873
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
+ }
1874
2783
  this.promoteNextBgQueuedToRunning();
1875
2784
  }
1876
2785
 
@@ -1888,13 +2797,21 @@ export class ChatSession {
1888
2797
  // as well would now double-index every window.
1889
2798
  maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
1890
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(); };
1891
2808
  try {
1892
2809
  if (!entry || !entry.storagePath) return;
1893
2810
  // The user stopped this file from its collapsed row. Dispatching the next
1894
2811
  // pass here is exactly what "stop" has to prevent — the cancelled pass
1895
2812
  // settles, and without this the chain simply carries on.
1896
2813
  if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
1897
- if (!isPagedReadFile(entry.filename, entry.mime)) return;
2814
+ if (!isPagedReadFile(entry.filename, entry.mime)) { endOfClientChain(); return; }
1898
2815
  if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop (PDF vision)
1899
2816
  // When windowed indexing is on, the WORKER drives the text/grid loop too. The
1900
2817
  // client MUST NOT also resume, or two drivers each enqueue a continuation per
@@ -1902,22 +2819,27 @@ export class ChatSession {
1902
2819
  // PDFs early-return above. Gated on the flag so the old client-driven path is
1903
2820
  // untouched when windowing is off.
1904
2821
  if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
1905
- if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
2822
+ if (isErrorResponseBody(response)) { endOfClientChain(); return; } // a failed pass is not "incomplete"
1906
2823
  var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
1907
- if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
2824
+ if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) { endOfClientChain(); return; } // fully indexed
1908
2825
  var pass = (entry.resumePass || 0) + 1;
1909
- 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
1910
2827
  var id = this.host.getIdentity();
1911
- if (!id || id.platform === 'none' || id.serviceId !== entry.serviceId) return;
2828
+ if (!id || id.platform === 'none' || id.projectId !== entry.projectId) return;
1912
2829
  // Counted as live work from here, not from the ack: awaitIndexingDrained
1913
2830
  // asks the SERVER what is queued, and this pass is not queued until the
1914
2831
  // call below returns. Without it a chat can slip in between two passes.
1915
2832
  this.trackIndexDispatch(notifyAgentContinueIndexing({
1916
2833
  platform: id.platform as 'claude' | 'openai',
1917
2834
  model: id.model,
1918
- 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,
1919
2841
  owner: id.owner,
1920
- userId: id.userId || id.serviceId,
2842
+ userId: id.userId || id.projectId,
1921
2843
  serviceName: id.serviceName,
1922
2844
  serviceDescription: id.serviceDescription,
1923
2845
  attachment: {
@@ -1930,11 +2852,18 @@ export class ChatSession {
1930
2852
  }).then(function (ack: any) {
1931
2853
  if (ack && typeof ack.id === 'string') {
1932
2854
  self.bgTaskQueue.push({
1933
- 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,
1934
2856
  filename: entry.filename, storagePath: entry.storagePath,
1935
2857
  isReindex: entry.isReindex, mime: entry.mime, size: entry.size,
1936
2858
  status: ack.status === 'running' ? 'running' : 'pending',
1937
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).
1938
2867
  });
1939
2868
  self.drainBgTaskQueue();
1940
2869
  }
@@ -1955,21 +2884,29 @@ export class ChatSession {
1955
2884
  // request is built from. The rescue below compares against this rather
1956
2885
  // than a live getHistoryCacheKey(), so a project switch mid-fetch can't
1957
2886
  // make another chat's in-flight bubbles look local.
1958
- var loadKey = (!id.serviceId || id.platform === 'none') ? '' : id.serviceId + '#' + id.platform;
2887
+ var loadKey = (!id.projectId || id.platform === 'none') ? '' : id.projectId + '#' + id.platform;
1959
2888
  if (token === undefined) token = this.state.gateRefreshToken;
1960
- 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) {
1961
2890
  return Promise.resolve();
1962
2891
  }
1963
2892
  this.state.historyRequestToken = token;
1964
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
+ }
1965
2902
  if (fetchMore) this.state.loadingOlderHistory = true;
1966
2903
  this.host.notify(); // surface "Fetching history..." while it loads
1967
2904
  var platform = id.platform as 'claude' | 'openai';
1968
- var serviceId = id.serviceId, owner = id.owner;
2905
+ var projectId = id.projectId, owner = id.owner;
1969
2906
  var options: any = { fetchMore: fetchMore };
1970
2907
  if (fetchMore && this.state.historyStartKeyHistory.length) options.startKeyHistory = this.state.historyStartKeyHistory.slice();
1971
2908
 
1972
- 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); };
1973
2910
 
1974
2911
  return Promise.resolve().then(fetchHistory).catch(function (err: any) {
1975
2912
  if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
@@ -1989,7 +2926,7 @@ export class ChatSession {
1989
2926
  });
1990
2927
  var mapped = mapHistoryListToMessages(list, platform, {
1991
2928
  clearedAt: self.host.getClearedAt(),
1992
- serviceId: id.serviceId,
2929
+ projectId: id.projectId,
1993
2930
  formatIndexingLabel: self.host.formatIndexingLabel,
1994
2931
  }).messages;
1995
2932
 
@@ -2135,6 +3072,25 @@ export class ChatSession {
2135
3072
  self.host.notify();
2136
3073
 
2137
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
+ }
2138
3094
  chatList.forEach(function (item: any) {
2139
3095
  if (item.status !== 'running' && item.status !== 'pending') return;
2140
3096
  if (!item.poll || !item.id) return;
@@ -2163,6 +3119,9 @@ export class ChatSession {
2163
3119
  // Background indexing polls are suppressed while paused; foreground
2164
3120
  // replies the user is waiting on are not.
2165
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;
2166
3125
  var capturedId = item.id;
2167
3126
  var pp = item.poll({
2168
3127
  latency: POLL_INTERVAL,
@@ -2218,6 +3177,11 @@ export class ChatSession {
2218
3177
  self.drainBgTaskQueue();
2219
3178
  }
2220
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
+
2221
3185
  // Sticky, NOT forcing: this runs after every first-page load, including
2222
3186
  // the one resumePolling fires on visibilitychange. Forcing yanked a
2223
3187
  // reader who had scrolled up back to the bottom. On a genuine mount the
@@ -2244,7 +3208,7 @@ export class ChatSession {
2244
3208
  // Upload one attachment (a file = 1 member, a folder = N) to db storage and
2245
3209
  // queue indexing per member. The bytes I/O + chip rendering go through host
2246
3210
  // hooks; the overwrite/reindex flow, status lifecycle, and indexing live here.
2247
- 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 }>> {
2248
3212
  var self = this;
2249
3213
  var id = this.host.getIdentity();
2250
3214
  att.status = 'uploading'; att.progress = 0; att.errorMessage = '';
@@ -2286,7 +3250,16 @@ export class ChatSession {
2286
3250
  var isExists = code === 'EXISTS' || (msg && /exist/i.test(msg));
2287
3251
  if (!isExists) throw err; // a member upload failed → whole attachment fails (red)
2288
3252
  return self.host.promptOverwrite(member.file.name).then(function (choice) {
2289
- 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
+ }
2290
3263
  if (choice === 'skip') { skipped = true; return; } // leave it untouched; no upload/index
2291
3264
  hadExists = true; existedBefore = true; // keep it; Reindex
2292
3265
  });
@@ -2306,23 +3279,53 @@ export class ChatSession {
2306
3279
  // enqueued. Best-effort + optional-hook guarded: a missing record,
2307
3280
  // a permission error, or a host without the hook must not block
2308
3281
  // indexing.
2309
- var preIndex = (existedBefore && typeof self.host.deleteExistingFileRecord === 'function')
2310
- ? Promise.resolve(self.host.deleteExistingFileRecord(member.storagePath)).catch(function () { })
2311
- : 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
+ });
2312
3313
  // Run a client-side attachment parser (e.g. .hwp) if one matches; its
2313
3314
  // output is inlined into the indexing request (falls back to office
2314
3315
  // extraction / web_fetch when no parser matches or it yields nothing).
2315
3316
  return preIndex.then(function () {
2316
3317
  return parseAttachmentContent(member.file, member.file.name, mime || undefined);
2317
3318
  }).then(function (parsedContent: string | null) {
3319
+ if (alreadyIndexing) return;
2318
3320
  // Tracked so a chat waiting on awaitIndexingDrained cannot be sent
2319
3321
  // in the window between this call and the queue accepting it.
2320
3322
  return self.trackIndexDispatch(notifyAgentSaveAttachment({
2321
3323
  platform: id.platform as 'claude' | 'openai',
2322
3324
  model: id.model,
2323
- service: id.serviceId,
3325
+ service: id.projectId,
3326
+ publicProjectId: id.publicProjectId,
2324
3327
  owner: id.owner,
2325
- userId: id.userId || id.serviceId,
3328
+ userId: id.userId || id.projectId,
2326
3329
  serviceName: id.serviceName,
2327
3330
  serviceDescription: id.serviceDescription,
2328
3331
  attachment: {
@@ -2333,7 +3336,7 @@ export class ChatSession {
2333
3336
  }).then(function (ack: any) {
2334
3337
  if (ack && typeof ack.id === 'string') {
2335
3338
  self.bgTaskQueue.push({
2336
- 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,
2337
3340
  filename: member.file.name,
2338
3341
  storagePath: member.storagePath,
2339
3342
  isReindex: hadExists,
@@ -2341,11 +3344,19 @@ export class ChatSession {
2341
3344
  size: member.file.size,
2342
3345
  status: ack.status === 'running' ? 'running' : 'pending',
2343
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,
2344
3351
  });
2345
3352
  self.drainBgTaskQueue(); // surface "Indexing: <file>" as soon as THIS file uploads
2346
3353
  }
2347
3354
  }, function (e: any) {
2348
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);
2349
3360
  anyIndexFailed = true; // uploaded but not indexed → yellow
2350
3361
  // Record the first index error's code/message for the report dialog.
2351
3362
  if (!att.errorCode && !att.errorDetail) {
@@ -2376,7 +3387,11 @@ export class ChatSession {
2376
3387
  // message — uploading those here would attach them to the wrong turn, and
2377
3388
  // collecting the previous batch's finished urls would attach files the user
2378
3389
  // already sent. Omitted (no batch) means every chip, the old behavior.
2379
- uploadPendingAttachments(batchId?: string): Promise<Array<{ name: string; url: string; storagePath?: string }>> {
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 }>> {
2380
3395
  var self = this;
2381
3396
  this.host.resetOverwriteBatch();
2382
3397
  this._uploadBatches += 1;
@@ -2398,7 +3413,7 @@ export class ChatSession {
2398
3413
  }
2399
3414
  if (att.uploadedUrl) { collected.push({ name: att.name, url: att.uploadedUrl, storagePath: att.storagePath }); return; }
2400
3415
  }
2401
- return self.uploadSingleAttachment(att).then(function (us) {
3416
+ return self.uploadSingleAttachment(att, stageId).then(function (us) {
2402
3417
  collected.push.apply(collected, us);
2403
3418
  }).catch(function (err: any) {
2404
3419
  var removed = !self.state.attachments.some(function (a: any) { return a.id === att.id; });