bunnyquery 1.8.5 → 1.8.8

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.
@@ -32,17 +32,21 @@ import {
32
32
  MAX_CONCURRENT_BG_POLLS,
33
33
  bgIndexingQueueName,
34
34
  isBgIndexingQueue,
35
+ buildHistoryItemFullId,
35
36
  ANTHROPIC_MESSAGES_API_URL,
36
37
  OPENAI_RESPONSES_API_URL,
38
+ upsertIndexRunRecordSafe,
39
+ type IndexRunStatus,
40
+ type IndexRunPatch,
37
41
  type BgTaskEntry,
38
42
  } from './requests';
39
43
  import { isPagedReadFile, isImageVisionFile, isWindowedReadFile } from './office';
40
- import { windowedIndexingEnabled } from './config';
44
+ import { windowedIndexingEnabled, chatEngineConfig } from './config';
41
45
  import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
42
46
  import { buildBoundedChatMessages } from './budget';
43
- import { createInlineLinkRegex } from './links';
47
+ import { createInlineLinkRegex, sanitizeAttachmentLinksForHistory } from './links';
44
48
  import { markImagePreviewStale } from './image_preview';
45
- import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText } from './history';
49
+ import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory } from './history';
46
50
  import { wallClockNow } from './time';
47
51
  import { parseAttachmentContent } from './attachment_parsers';
48
52
  import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
@@ -92,6 +96,11 @@ const INDEXING_DRAIN_IDLE_LOOKS = 2;
92
96
  // about. Costs nothing in the normal case, where the queue reports work
93
97
  // immediately and the wait is far longer than this anyway.
94
98
  const INDEXING_DRAIN_MIN_MS = 8000;
99
+
100
+ // Ownership sequence for state.bgHistoryLoading (see the deferred-batch handler
101
+ // in loadHistory): the latest minted batch owns the flag; older handlers
102
+ // settling late must neither clear nor keep it.
103
+ let _bgHistoryBatchSeq = 0;
95
104
  // Ceiling on that wait. Past it the turn is sent regardless: an answer computed
96
105
  // against a partly-indexed file is a poor outcome, but a question that is never
97
106
  // asked at all because one chain wedged server-side is a worse one.
@@ -216,6 +225,9 @@ export class ChatSession {
216
225
  typingAbort: false,
217
226
  loadingHistory: false,
218
227
  loadingOlderHistory: false,
228
+ // A deferred bg stub batch (first-paint split) is still in flight; the
229
+ // views show a small 'loading indexing history' hint while true.
230
+ bgHistoryLoading: false,
219
231
  historyEndOfList: false,
220
232
  historyStartKeyHistory: [],
221
233
  historyRequestToken: 0,
@@ -384,11 +396,13 @@ export class ChatSession {
384
396
  return Promise.resolve();
385
397
  }
386
398
  var queue = bgIndexingQueueName(id.userId, id.projectId);
399
+ // Snapshot reader: a young shared answer (this session's own adopt
400
+ // ladder, or the db-files page's probe) is as good as a fresh one.
387
401
  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; });
402
+ return Promise.resolve(probeBgQueue(
403
+ { service: id.projectId, owner: id.owner, platform: platform as 'claude' | 'openai', queue: queue, status: status, limit: WORKER_PASS_ADOPT_LIMIT },
404
+ { maxAgeMs: BG_PROBE_TTL_MS },
405
+ )).then(function (entry) { return entry.result; }).catch(function () { return null; });
392
406
  };
393
407
  return Promise.all([ask('pending'), ask('running')]).then(function (results) {
394
408
  if (results[0] === null || results[1] === null) return;
@@ -500,7 +514,13 @@ export class ChatSession {
500
514
  * instead of merely unconfirmed.
501
515
  */
502
516
  refreshLiveIndexState(): void {
503
- this._adoptWorkerIndexingPasses(0);
517
+ // PASSIVE: this is the first-page/resume refresh, not a settle. One
518
+ // fresh look (two status queries) always happens; the 2s/6s ladder
519
+ // climb is reserved for when there is EVIDENCE of live work — the
520
+ // pass-boundary gap the ladder exists for presumes a chain was alive,
521
+ // and on an idle tab return the climb was three round trips of asking
522
+ // a question whose answer had not changed.
523
+ this._adoptWorkerIndexingPasses(0, true);
504
524
  }
505
525
 
506
526
  /** Forget what we know about which files are indexing — but ONLY when the
@@ -688,6 +708,79 @@ export class ChatSession {
688
708
  return id.projectId + '#' + id.platform;
689
709
  }
690
710
 
711
+ // ─── compact-stub hydration ─────────────────────────────────────────────
712
+ // Split-fetch bg pages arrive as label stubs (no bodies). When the user
713
+ // expands a row, the real reply text is fetched per item (csr-poll point
714
+ // lookup) and MEMOIZED per chat: every later remap (first-page refresh,
715
+ // queue-detect tick, cache restore) re-applies the memo, so a hydrated
716
+ // bubble can never silently revert to its 200-char head.
717
+ private _hydratedBodies: { [chatKey: string]: { [itemId: string]: string } } = {};
718
+ private _hydratingItems: { [key: string]: boolean } = {};
719
+
720
+ /** Re-apply memoized hydrated texts onto freshly-mapped messages. Both
721
+ * clients call this right after their mapper runs (loadHistory does it
722
+ * internally); it mutates the given array's items in place. */
723
+ applyHydratedBodies(messages: ChatMessage[]): void {
724
+ var key = this.getHistoryCacheKey();
725
+ var memo = key ? this._hydratedBodies[key] : null;
726
+ if (!memo) return;
727
+ var id = this.host.getIdentity();
728
+ for (var i = 0; i < messages.length; i++) {
729
+ var m: any = messages[i];
730
+ if (!m || !m._compact || m.role !== 'assistant' || !m._serverItemId) continue;
731
+ var text = memo[m._serverItemId];
732
+ if (typeof text !== 'string') continue;
733
+ m.content = sanitizeAttachmentLinksForHistory(text, id.projectId, true) || EMPTY_INDEXING_REPLY;
734
+ delete m._compact;
735
+ }
736
+ }
737
+
738
+ /** Fetch the real response bodies for compact history stubs (one csr-poll
739
+ * point lookup per item id), memoize, and swap them into the live list.
740
+ * Best-effort: a failed lookup leaves the stub (its head + fallback line
741
+ * still render) and a later expand retries. */
742
+ hydrateCompactItems(itemIds: string[]): Promise<void> {
743
+ var self = this;
744
+ var lookup = chatEngineConfig().csrHistoryItemLookup;
745
+ if (!lookup || !itemIds || !itemIds.length) return Promise.resolve();
746
+ var id = this.host.getIdentity();
747
+ var platform = id.platform;
748
+ if (!id.projectId || (platform !== 'claude' && platform !== 'openai')) return Promise.resolve();
749
+ var chatKey = this.getHistoryCacheKey();
750
+ if (!chatKey) return Promise.resolve();
751
+ var jobs = itemIds.map(function (itemId) {
752
+ if (!itemId) return Promise.resolve();
753
+ var already = self._hydratedBodies[chatKey] && self._hydratedBodies[chatKey][itemId] !== undefined;
754
+ var inflightKey = chatKey + '|' + itemId;
755
+ if (already || self._hydratingItems[inflightKey]) return Promise.resolve();
756
+ self._hydratingItems[inflightKey] = true;
757
+ return Promise.resolve(lookup(buildHistoryItemFullId(platform as 'claude' | 'openai', id.projectId, itemId), id.projectId, id.owner)).then(function (body: any) {
758
+ // Chat may have switched while the lookup flew; the memo is
759
+ // per-chat so record under the key we started with, and only
760
+ // touch the live list if it is still that chat's.
761
+ var text = ((platform === 'openai' ? extractOpenAIText(body) : extractClaudeText(body)) || '').trim();
762
+ if (text.indexOf(INDEXING_COMPLETE_MARKER) !== -1) text = text.split(INDEXING_COMPLETE_MARKER).join('').trim();
763
+ if (!self._hydratedBodies[chatKey]) self._hydratedBodies[chatKey] = {};
764
+ self._hydratedBodies[chatKey][itemId] = text;
765
+ if (self.getHistoryCacheKey() !== chatKey) return;
766
+ for (var i = 0; i < self.state.messages.length; i++) {
767
+ var m: any = self.state.messages[i];
768
+ if (m && m._compact && m.role === 'assistant' && m._serverItemId === itemId) {
769
+ m.content = sanitizeAttachmentLinksForHistory(text, id.projectId, true) || EMPTY_INDEXING_REPLY;
770
+ delete m._compact;
771
+ }
772
+ }
773
+ }).catch(function () { /* stub stays; expand retries */ }).then(function () {
774
+ delete self._hydratingItems[inflightKey];
775
+ });
776
+ });
777
+ return Promise.all(jobs).then(function () {
778
+ if (self.getHistoryCacheKey() !== chatKey) return;
779
+ self.host.notify();
780
+ self.updateHistoryCache();
781
+ });
782
+ }
783
+
691
784
  updateHistoryCache(): void {
692
785
  var key = this.getHistoryCacheKey();
693
786
  if (!key) return;
@@ -1069,10 +1162,13 @@ export class ChatSession {
1069
1162
  res(v);
1070
1163
  };
1071
1164
  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); });
1165
+ // maxAgeMs 0: this loop is WAITING for the queue to change, so a
1166
+ // cached answer is useless to it — but its fresh answers write
1167
+ // through into the shared probe memo for the snapshot readers.
1168
+ Promise.resolve(probeBgQueue(
1169
+ { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status, limit: WORKER_PASS_ADOPT_LIMIT },
1170
+ { maxAgeMs: 0 },
1171
+ )).then(function (entry: any) { settle(entry.result); }, function () { settle(null); });
1076
1172
  });
1077
1173
  };
1078
1174
  // Ordinary chats are routed onto this queue too (_isOnBgQueue), and those
@@ -1837,6 +1933,32 @@ export class ChatSession {
1837
1933
  if (e && e.id && self._indexKeyOf(e) === scoped) stoppedIds[e.id] = true;
1838
1934
  });
1839
1935
  this.state.stoppedIndexIds = stoppedIds;
1936
+ // Close the durable run:: record too — inside this !finished guard on
1937
+ // purpose, for the same reason as stoppedIndexIds: a stop that landed
1938
+ // after the run was over must not relabel an indexed file as cancelled.
1939
+ // Path over key: `key` degrades to a bare filename for path-less compact
1940
+ // stubs, and run:: is keyed by storage path only — no derivable path
1941
+ // means no record to close (restored stubs of old runs), which is fine.
1942
+ var runPath = group.path || '';
1943
+ if (!runPath) {
1944
+ (group.members || []).some(function (m) {
1945
+ var p = m && m.msg && m.msg._indexFile && m.msg._indexFile.path;
1946
+ if (p) { runPath = p; return true; }
1947
+ return false;
1948
+ });
1949
+ }
1950
+ if (!runPath) {
1951
+ this.bgTaskQueue.some(function (e) {
1952
+ if (e && e.storagePath && self._indexKeyOf(e) === scoped) { runPath = e.storagePath; return true; }
1953
+ return false;
1954
+ });
1955
+ }
1956
+ if (runPath) {
1957
+ var ident = this.host.getIdentity();
1958
+ if (ident && ident.projectId) {
1959
+ upsertIndexRunRecordSafe(ident.projectId, runPath, { status: 'cancelled', finished: Date.now() });
1960
+ }
1961
+ }
1840
1962
  }
1841
1963
  // Ask the QUEUE what else is live for this file, and cancel that too.
1842
1964
  //
@@ -2449,7 +2571,7 @@ export class ChatSession {
2449
2571
  * shape that previously looped fetchHistoryPage after an already-DONE index.
2450
2572
  */
2451
2573
  private _adoptingWorkerPasses = false;
2452
- private _adoptWorkerIndexingPasses(attempt: number): void {
2574
+ private _adoptWorkerIndexingPasses(attempt: number, passive?: boolean): void {
2453
2575
  var self = this;
2454
2576
  // One in flight at a time. The query is queue-WIDE, so a second file's pass
2455
2577
  // settling in the same moment is covered by the query already running.
@@ -2460,11 +2582,15 @@ export class ChatSession {
2460
2582
  if (this.isPollingPaused() || !this.host.isViewMounted()) return;
2461
2583
  var svcId = id.projectId, owner = id.owner;
2462
2584
  var queue = bgIndexingQueueName(id.userId, id.projectId);
2585
+ // maxAgeMs 0: adoption exists to notice passes the worker JUST minted,
2586
+ // so it must always look fresh — but its answers write through into the
2587
+ // shared probe memo, which is what keeps the snapshot readers (and the
2588
+ // db-files page's badge probe) from re-asking seconds later.
2463
2589
  var ask = function (status: 'pending' | 'running') {
2464
- return Promise.resolve(getChatHistory(
2465
- { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
2466
- { limit: WORKER_PASS_ADOPT_LIMIT },
2467
- )).catch(function () { return null; });
2590
+ return Promise.resolve(probeBgQueue(
2591
+ { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status, limit: WORKER_PASS_ADOPT_LIMIT },
2592
+ { maxAgeMs: 0 },
2593
+ )).then(function (entry) { return entry.result; }).catch(function () { return null; });
2468
2594
  };
2469
2595
  this._adoptingWorkerPasses = true;
2470
2596
  Promise.all([ask('running'), ask('pending')]).then(function (results: any[]) {
@@ -2505,6 +2631,15 @@ export class ChatSession {
2505
2631
  // empty queue for a chain that is very much alive — and with no pass
2506
2632
  // left to settle, nothing would ever ask again. Look once or twice more
2507
2633
  // before believing the file is finished.
2634
+ //
2635
+ // PASSIVE calls (first-page/resume refresh) climb only on EVIDENCE of
2636
+ // live work — a queued local entry for this project, a live key the
2637
+ // look above just recorded, or an attached poll. The gap rationale
2638
+ // presumes a chain was alive; with nothing suggesting one, the climb
2639
+ // re-asked an unchanged question three more times on every tab return.
2640
+ // (No nudge here either: a send waiting on indexing implies local
2641
+ // entries, which IS evidence, so the passive stop never starves it.)
2642
+ if (passive && !self._hasLiveIndexEvidence(svcId)) return;
2508
2643
  if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) {
2509
2644
  // The ladder is the only thing that can tell a worker-driven chain has
2510
2645
  // really ended, and it just did. This is the earliest honest moment to
@@ -2516,11 +2651,29 @@ export class ChatSession {
2516
2651
  var later = self.host.getIdentity();
2517
2652
  if (later.projectId !== svcId || later.platform !== platform) return;
2518
2653
  if (self.isPollingPaused() || !self.host.isViewMounted()) return;
2519
- self._adoptWorkerIndexingPasses(attempt + 1);
2654
+ self._adoptWorkerIndexingPasses(attempt + 1, passive);
2520
2655
  }, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
2521
2656
  }, function () { self._adoptingWorkerPasses = false; });
2522
2657
  }
2523
2658
 
2659
+ /** Anything at all suggesting THIS project's indexing may be live: a queued
2660
+ * local entry, a recorded live key (the adopt look just wrote them), or an
2661
+ * attached poll. Gates the passive adopt ladder's climb. */
2662
+ private _hasLiveIndexEvidence(svcId: string): boolean {
2663
+ for (var i = 0; i < this.bgTaskQueue.length; i++) {
2664
+ var e = this.bgTaskQueue[i];
2665
+ if (e && e.projectId === svcId) return true;
2666
+ }
2667
+ var keys = this.state.liveIndexKeys || {};
2668
+ for (var k in keys) { if (keys[k]) return true; }
2669
+ // BG polls only: a pending ORDINARY chat turn also holds a poll, and
2670
+ // counting it made every tab return during a conversation climb the
2671
+ // full passive ladder for indexing that does not exist.
2672
+ var found = false;
2673
+ this.historyItemPolls.forEach(function (h: any) { if (h && h.kind === 'bg') found = true; });
2674
+ return found;
2675
+ }
2676
+
2524
2677
  /** Any of these ids still queued or still polled, i.e. surviving work. */
2525
2678
  private _isTrackingAny(ids: string[]): boolean {
2526
2679
  for (var i = 0; i < ids.length; i++) {
@@ -2631,7 +2784,16 @@ export class ChatSession {
2631
2784
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
2632
2785
  var e = this.bgTaskQueue[i];
2633
2786
  if (e.projectId !== svcId || e.platform !== plat) continue;
2634
- if (presentIds[e.id] && !pendingIds[e.id]) this.bgTaskQueue.splice(i, 1);
2787
+ if (presentIds[e.id] && !pendingIds[e.id]) {
2788
+ // Settled while nobody was polling (hidden tab, dead poll): the
2789
+ // answer reached the chat as HISTORY, so no settle handler ever
2790
+ // ran for this entry — maybeResumeIndexing's flips included. For
2791
+ // a single-pass file that settle IS the whole run; close its
2792
+ // records off the settled bubbles' own flags before the entry
2793
+ // goes. (8 of 10 files in a real batch hit exactly this.)
2794
+ this._flipRunFromSettledEntry(e);
2795
+ this.bgTaskQueue.splice(i, 1);
2796
+ }
2635
2797
  }
2636
2798
  // Poll budget for this drain (see MAX_CONCURRENT_BG_POLLS). bgTaskQueue is
2637
2799
  // in push order, i.e. oldest first, which is also the order the server
@@ -2755,6 +2917,13 @@ export class ChatSession {
2755
2917
  // looks into the window where the new pass is not yet in the status
2756
2918
  // index, which is the same way a nudge on work STARTING went wrong.
2757
2919
  if (!self._isWorkerDrivenIndexing(capturedEntry.filename, capturedEntry.mime)) {
2920
+ // Also close the run record: for a CLIENT-driven file a failed
2921
+ // pass ends the chain on every path, and a removed row reads as
2922
+ // a cancel. Worker-driven files are excluded for the same reason
2923
+ // as the nudge — this catch fires on dropped polls too, and the
2924
+ // worker may be writing the next pass regardless.
2925
+ if (isNotExists) self._flipRunRecord(capturedEntry, 'cancelled');
2926
+ else self._flipRunRecord(capturedEntry, 'error', self._runErrorText(err));
2758
2927
  self._nudgeIndexingDrain();
2759
2928
  }
2760
2929
  }).then(function () {
@@ -2795,6 +2964,76 @@ export class ChatSession {
2795
2964
  // memory (a reload or a closed tab ended it), and it stopped whenever the model claimed
2796
2965
  // completion, which on an 88-page file happened at page 15. Continuing to dispatch here
2797
2966
  // as well would now double-index every window.
2967
+ /** Fire the consumer's done::-marker hook for a run whose completion this
2968
+ * client knows DETERMINISTICALLY (see the two call sites in
2969
+ * maybeResumeIndexing). Best-effort by contract; identity-checked so a
2970
+ * project switch mid-settle cannot stamp the wrong service. */
2971
+ _mintDoneMarker(entry: BgTaskEntry): void {
2972
+ try {
2973
+ var mint = chatEngineConfig().mintIndexDoneMarker;
2974
+ if (!mint || !entry || !entry.storagePath || !entry.projectId) return;
2975
+ var id = this.host.getIdentity();
2976
+ if (!id || id.projectId !== entry.projectId) return;
2977
+ mint({ service: entry.projectId, storagePath: entry.storagePath });
2978
+ } catch (_e) { /* best-effort */ }
2979
+ }
2980
+
2981
+ /** Short, storable form of an error body for the run:: record. */
2982
+ _runErrorText(response: any): string {
2983
+ var msg = '';
2984
+ try { msg = String(getErrorMessage(response) || ''); } catch (_e) { }
2985
+ msg = msg.replace(/\s+/g, ' ').trim();
2986
+ return msg ? msg.slice(0, 300) : 'Indexing failed.';
2987
+ }
2988
+
2989
+ /** Close the records of a run whose pass settled OFF-POLL — the answer came
2990
+ * back as history (hidden tab, dead poll, resume refetch), so none of the
2991
+ * poll-side settle handlers ran. Only for SINGLE-PASS files, where one
2992
+ * settled pass is deterministically the whole run (the same contract as
2993
+ * maybeResumeIndexing's single-pass branch); paged files stay with their
2994
+ * drivers. Outcome is read from the settled bubbles' own flags, which is
2995
+ * all the history mapping left us. Best-effort and idempotent throughout. */
2996
+ _flipRunFromSettledEntry(entry: BgTaskEntry): void {
2997
+ try {
2998
+ if (!entry || !entry.storagePath || !entry.id || !entry.projectId) return;
2999
+ if (isPagedReadFile(entry.filename, entry.mime)) return;
3000
+ // A stopped run's verdict belongs to the stop funnel — never let a
3001
+ // late clean settle of its pass relabel 'cancelled' as 'done'.
3002
+ if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
3003
+ if (this.state.stoppedIndexIds[entry.id]) return;
3004
+ var userMsg: any = null, replyMsg: any = null;
3005
+ this.state.messages.forEach(function (m: any) {
3006
+ if (m._serverItemId !== entry.id) return;
3007
+ if (m.role === 'user') { if (!userMsg) userMsg = m; }
3008
+ else if (!replyMsg) replyMsg = m;
3009
+ });
3010
+ if ((userMsg && userMsg.isCancelled) || (replyMsg && replyMsg.isCancelled)) {
3011
+ this._flipRunRecord(entry, 'cancelled');
3012
+ } else if (replyMsg && replyMsg.isError) {
3013
+ var errText = typeof replyMsg.content === 'string'
3014
+ ? replyMsg.content.replace(/\s+/g, ' ').trim().slice(0, 300) : '';
3015
+ this._flipRunRecord(entry, 'error', errText || 'Indexing failed.');
3016
+ } else if (replyMsg) {
3017
+ this._mintDoneMarker(entry);
3018
+ this._flipRunRecord(entry, 'done');
3019
+ }
3020
+ // No reply bubble at all: nothing to judge from — leave the record.
3021
+ } catch (_e) { /* best-effort */ }
3022
+ }
3023
+
3024
+ /** Close the durable run:: record for an ending THIS client observed.
3025
+ * service comes from the ENTRY, not the current identity: unlike the done::
3026
+ * mint above, a status flip must land even if the user switched projects
3027
+ * mid-settle — otherwise the record lies 'working' forever. Best-effort
3028
+ * through upsertIndexRunRecordSafe; the consumer's precedence guard keeps
3029
+ * repeats and races harmless. */
3030
+ _flipRunRecord(entry: BgTaskEntry, status: IndexRunStatus, error?: string): void {
3031
+ if (!entry || !entry.storagePath || !entry.projectId) return;
3032
+ var patch: IndexRunPatch = { status: status, finished: Date.now() };
3033
+ if (error) patch.error = error;
3034
+ upsertIndexRunRecordSafe(entry.projectId, entry.storagePath, patch);
3035
+ }
3036
+
2798
3037
  maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
2799
3038
  var self = this;
2800
3039
  // This client is the ONLY driver of the chains that reach the returns below,
@@ -2811,7 +3050,31 @@ export class ChatSession {
2811
3050
  // pass here is exactly what "stop" has to prevent — the cancelled pass
2812
3051
  // settles, and without this the chain simply carries on.
2813
3052
  if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
2814
- if (!isPagedReadFile(entry.filename, entry.mime)) { endOfClientChain(); return; }
3053
+ if (!isPagedReadFile(entry.filename, entry.mime)) {
3054
+ // Single-pass file: one settled pass IS the whole job. This is
3055
+ // deterministic knowledge (this client is the only dispatcher),
3056
+ // so record the durable done:: marker — single-pass runs never
3057
+ // get one from the worker. Errors don't count as completion
3058
+ // (this branch sits above the error check on purpose: the chain
3059
+ // is over either way, but only success gets a marker), and
3060
+ // neither does a CANCELLED poll shape — a row stopped from
3061
+ // another tab/device settles through here as {status:
3062
+ // 'cancelled', queue fields} with no provider payload, and
3063
+ // cancelledIndexKeys only records stops issued from THIS tab.
3064
+ if (!isErrorResponseBody(response) && !this._isCancelledPollResult(response)) {
3065
+ this._mintDoneMarker(entry);
3066
+ this._flipRunRecord(entry, 'done');
3067
+ } else if (this._isCancelledPollResult(response)) {
3068
+ // Stopped elsewhere (another tab/device): the stopping side
3069
+ // usually wrote 'cancelled' already; writing it again is a
3070
+ // same-value terminal update, and it closes the record when
3071
+ // the stopping tab died before its write landed.
3072
+ this._flipRunRecord(entry, 'cancelled');
3073
+ } else {
3074
+ this._flipRunRecord(entry, 'error', this._runErrorText(response));
3075
+ }
3076
+ endOfClientChain(); return;
3077
+ }
2815
3078
  if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop (PDF vision)
2816
3079
  // When windowed indexing is on, the WORKER drives the text/grid loop too. The
2817
3080
  // client MUST NOT also resume, or two drivers each enqueue a continuation per
@@ -2819,13 +3082,41 @@ export class ChatSession {
2819
3082
  // PDFs early-return above. Gated on the flag so the old client-driven path is
2820
3083
  // untouched when windowing is off.
2821
3084
  if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
2822
- if (isErrorResponseBody(response)) { endOfClientChain(); return; } // a failed pass is not "incomplete"
3085
+ if (isErrorResponseBody(response)) {
3086
+ // A failed pass is not "incomplete" — the client-driven chain is over.
3087
+ this._flipRunRecord(entry, 'error', this._runErrorText(response));
3088
+ endOfClientChain(); return;
3089
+ }
2823
3090
  var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
2824
- if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) { endOfClientChain(); return; } // fully indexed
3091
+ if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) {
3092
+ // Client-driven chain, and the model's reply carried the
3093
+ // completion token — the same signal the worker requires before
3094
+ // minting for its own chains. Deterministic here: this client
3095
+ // is the only dispatcher and it is deciding, right now, that no
3096
+ // further pass will run.
3097
+ this._mintDoneMarker(entry);
3098
+ this._flipRunRecord(entry, 'done');
3099
+ endOfClientChain(); return; // fully indexed
3100
+ }
2825
3101
  var pass = (entry.resumePass || 0) + 1;
2826
- if (pass > MAX_INDEXING_RESUME_PASSES) { endOfClientChain(); return; } // give up after the cap
3102
+ if (pass > MAX_INDEXING_RESUME_PASSES) {
3103
+ // Gave up after the cap: the file is only partially indexed, and no
3104
+ // further pass will ever run. 'error' rather than 'done' — the badge
3105
+ // must not claim a file the chain abandoned.
3106
+ this._flipRunRecord(entry, 'error', 'Stopped after ' + MAX_INDEXING_RESUME_PASSES + ' passes without finishing.');
3107
+ endOfClientChain(); return;
3108
+ }
2827
3109
  var id = this.host.getIdentity();
2828
- if (!id || id.platform === 'none' || id.projectId !== entry.projectId) return;
3110
+ if (!id || id.platform === 'none' || id.projectId !== entry.projectId) {
3111
+ // This client is the only driver of this chain and it just declined
3112
+ // to dispatch the next pass — the chain is over, as an ending, not a
3113
+ // pause: the queue entry is spliced on settle and nothing revisits
3114
+ // it. Close the record (it writes with entry.projectId, so it lands
3115
+ // after a project switch) or it lies 'working' for the stale window.
3116
+ this._flipRunRecord(entry, 'error', 'Indexing stopped: the session or project changed before the file finished.');
3117
+ endOfClientChain();
3118
+ return;
3119
+ }
2829
3120
  // Counted as live work from here, not from the ack: awaitIndexingDrained
2830
3121
  // asks the SERVER what is queued, and this pass is not queued until the
2831
3122
  // call below returns. Without it a chat can slip in between two passes.
@@ -2905,8 +3196,15 @@ export class ChatSession {
2905
3196
  var projectId = id.projectId, owner = id.owner;
2906
3197
  var options: any = { fetchMore: fetchMore };
2907
3198
  if (fetchMore && this.state.historyStartKeyHistory.length) options.startKeyHistory = this.state.historyStartKeyHistory.slice();
3199
+ // First page: paint the CONVERSATION immediately and let the indexing
3200
+ // stubs arrive as a deferred batch (merged below when it lands).
3201
+ if (!fetchMore) options.deferBg = true;
2908
3202
 
2909
- var fetchHistory = function () { return getChatHistory({ service: projectId, owner: owner, platform: platform }, options); };
3203
+ // Split fetch v2: surface (full bodies, bg queue excluded) + bg compact
3204
+ // stubs, tiled to the surface page's time window with module-level
3205
+ // state (buffering + retry safety) — see getSplitChatHistory. Returns
3206
+ // the exact single-fetch shape; startKeyHistory is bookkeeping only.
3207
+ var fetchHistory = function () { return getSplitChatHistory({ service: projectId, owner: owner, platform: platform, userId: id.userId }, options); };
2910
3208
 
2911
3209
  return Promise.resolve().then(fetchHistory).catch(function (err: any) {
2912
3210
  if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
@@ -2916,7 +3214,11 @@ export class ChatSession {
2916
3214
  var chatList = history && Array.isArray(history.list) ? history.list : [];
2917
3215
  chatList.forEach(function (item: any) {
2918
3216
  if (isBgIndexingQueue(item.queue_name)) {
2919
- if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) item._isBgTask = true;
3217
+ // Compact stubs carry the label as request_text — the body
3218
+ // never left the server — so classify from whichever form
3219
+ // this item arrived in.
3220
+ var clsText = item.compact ? item.request_text : extractLastUserTextFromRequest(item.request_body);
3221
+ if (isIndexingRequestText(clsText)) item._isBgTask = true;
2920
3222
  else item._isOnBgQueue = true;
2921
3223
  }
2922
3224
  });
@@ -2929,17 +3231,77 @@ export class ChatSession {
2929
3231
  projectId: id.projectId,
2930
3232
  formatIndexingLabel: self.host.formatIndexingLabel,
2931
3233
  }).messages;
3234
+ // Re-apply any previously-hydrated compact bodies: a refresh maps
3235
+ // fresh stubs, and without this a bubble the user already expanded
3236
+ // would silently revert to its 200-char head.
3237
+ self.applyHydratedBodies(mapped);
2932
3238
 
2933
3239
  // Set when a first-page refresh re-prepended already-loaded older pages,
2934
3240
  // so the cursor reset below knows not to rewind to page 1's boundary.
2935
3241
  var keptOlderPages = false;
3242
+ // The empty-page-1 keep below was taken ONLY on the strength of a
3243
+ // deferred batch still flying — if that batch then proves the chat
3244
+ // empty server-side, the kept screen must be let go (see the batch
3245
+ // handler), or an emptied transcript is indelible.
3246
+ var keptScreenAwaitingBg = false;
2936
3247
 
2937
3248
  if (fetchMore) {
2938
- self.state.messages = mapped.concat(self.state.messages);
3249
+ // STRIP-THEN-MERGE keyed on SERVER ITEM ID. Incoming bubbles are
3250
+ // the fresher server state (queued→running→settled transitions),
3251
+ // so existing copies of incoming id|role bubbles are REMOVED first
3252
+ // (in-flight cancel flags carried over), then the page merges by
3253
+ // item id — ids are creation-ordered and BOTH lists are already
3254
+ // id-sorted, unlike _ts (assistant _ts is `updated`, which any
3255
+ // long-running turn makes non-monotonic and would misorder the
3256
+ // walk). Id-less bubbles on screen (staged/local, always the tail)
3257
+ // end the merge region; id-less incoming sorts first (oldest).
3258
+ var incomingKeys: any = {};
3259
+ mapped.forEach(function (m: any) { if (m._serverItemId) incomingKeys[m._serverItemId + '|' + m.role] = m; });
3260
+ var existing = self.state.messages.filter(function (m: any) {
3261
+ if (!m._serverItemId) return true;
3262
+ var inc = incomingKeys[m._serverItemId + '|' + m.role];
3263
+ if (!inc) return true;
3264
+ if (m._cancelling) inc._cancelling = m._cancelling;
3265
+ if (m._cancelError) inc._cancelError = m._cancelError;
3266
+ return false;
3267
+ });
3268
+ var mergedList: ChatMessage[] = [];
3269
+ var pi = 0, ei = 0;
3270
+ while (pi < mapped.length && ei < existing.length) {
3271
+ var pm: any = mapped[pi], em: any = existing[ei];
3272
+ var eid = em._serverItemId;
3273
+ if (typeof eid !== 'string') break; // staged/local tail
3274
+ var pid = pm._serverItemId;
3275
+ if (typeof pid !== 'string' || pid <= eid) { mergedList.push(pm); pi++; }
3276
+ else { mergedList.push(em); ei++; }
3277
+ }
3278
+ while (pi < mapped.length) mergedList.push(mapped[pi++]);
3279
+ while (ei < existing.length) mergedList.push(existing[ei++]);
3280
+ self.state.messages = mergedList;
3281
+ } else if (!mapped.length && history && (history.endOfList === false || (history as any).bgPending)
3282
+ && self.state.messages.some(function (m: any) { return m._ownerKey === undefined || m._ownerKey === loadKey; })) {
3283
+ // Empty first page that cannot judge the chat empty: either
3284
+ // empty-but-not-end (a pure-bg band past every hop cap), or an
3285
+ // empty DEFERRED page whose bg batch is still flying — a chat
3286
+ // whose whole conversation rides the bg queue has a genuinely
3287
+ // empty surface (endOfList true on a head refresh), and the
3288
+ // batch carries everything. REPLACING with it would blank the
3289
+ // chat for the batch's flight time — the greeting flashed over
3290
+ // real messages on every tab return. Keep what is on screen.
3291
+ // Kept ONLY on the batch's word: the batch handler revisits.
3292
+ if (history.endOfList !== false) keptScreenAwaitingBg = true;
2939
3293
  } else {
2940
3294
  if (self.state.typing) self.state.typingAbort = true;
2941
3295
  var serverIds: any = {};
2942
3296
  mapped.forEach(function (m: any) { if (m._serverItemId) serverIds[m._serverItemId] = 1; });
3297
+ // The retention boundary must be the SURFACE frontier: page 1 also
3298
+ // emits bg stubs reaching arbitrarily deep, and keying retention on
3299
+ // a weeks-old stub id would wipe (or gap) the scrolled-in pages.
3300
+ var surfaceOldestId: string | undefined = undefined;
3301
+ mapped.forEach(function (m: any) {
3302
+ if (typeof m._serverItemId !== 'string' || m._fromBgChain) return;
3303
+ if (surfaceOldestId === undefined || m._serverItemId < surfaceOldestId) surfaceOldestId = m._serverItemId;
3304
+ });
2943
3305
  var locallyCancelled: any = {};
2944
3306
  self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = m; });
2945
3307
  // A cancel the server has not acknowledged yet. Without carrying these
@@ -3009,7 +3371,13 @@ export class ChatSession {
3009
3371
  var sharesPage1 = self.state.messages.some(function (m) {
3010
3372
  return typeof m._serverItemId === 'string' && !!serverIds[m._serverItemId as string];
3011
3373
  });
3012
- var retainedOlder: ChatMessage[] = (!sharesPage1 || oldestInPage1 === undefined) ? [] : self.state.messages.filter(function (m) {
3374
+ var deferredBg = !!(history && (history as any).bgPending);
3375
+ // Boundary for retention: the SURFACE frontier when page 1 has any
3376
+ // surface item (bg stubs can reach weeks deeper and would otherwise
3377
+ // wipe or gap the scrolled-in pages); whole-page oldest only as the
3378
+ // fallback for a surface-empty page.
3379
+ var retainBoundary: string | undefined = surfaceOldestId !== undefined ? surfaceOldestId : oldestInPage1;
3380
+ var retainedOlder: ChatMessage[] = (!sharesPage1 || retainBoundary === undefined) ? [] : self.state.messages.filter(function (m) {
3013
3381
  // Settled server history only; in-flight bubbles are the rescue
3014
3382
  // loop's job below and would otherwise be kept twice.
3015
3383
  if (typeof m._serverItemId !== 'string') return false;
@@ -3017,10 +3385,49 @@ export class ChatSession {
3017
3385
  // project's bubbles onto this chat (the cross-project leak fix).
3018
3386
  // Compare against the snapshotted loadKey, never a live read.
3019
3387
  if (m._ownerKey !== undefined && m._ownerKey !== loadKey) return false;
3020
- return (m._serverItemId as string) < (oldestInPage1 as string);
3388
+ // While a deferred stub batch is in flight, KEEP already-loaded
3389
+ // indexing bubbles wherever they sit — the surface-only page has
3390
+ // none, and dropping them here would flicker every row until the
3391
+ // batch lands (its apply strips+re-merges them by id anyway).
3392
+ if (deferredBg && m.isBackgroundTask) return true;
3393
+ // Bg-chain bubbles already on screen stay put regardless of the
3394
+ // boundary — their ids interleave with deep history and the strip
3395
+ // in later merges replaces them when fresher copies arrive.
3396
+ if (m._fromBgChain) return true;
3397
+ return (m._serverItemId as string) < (retainBoundary as string);
3398
+ });
3399
+ // Prepend only the STRICTLY-older bubbles. Retained bg bubbles whose
3400
+ // ids sit inside page 1's own range are ID-MERGED into it instead —
3401
+ // blanket prepending hoisted every indexing row above the whole
3402
+ // conversation, and after a head refresh the single-page batch
3403
+ // never re-placed the deep ones, so they sat hoisted for good.
3404
+ var prependOlder: ChatMessage[] = [];
3405
+ var interleave: ChatMessage[] = [];
3406
+ retainedOlder.forEach(function (m) {
3407
+ var sid = m._serverItemId as string;
3408
+ if (serverIds[sid]) return; // page 1 carries a fresher copy
3409
+ if (retainBoundary !== undefined && sid < (retainBoundary as string)) prependOlder.push(m);
3410
+ else interleave.push(m);
3021
3411
  });
3022
- keptOlderPages = retainedOlder.length > 0;
3023
- self.state.messages = keptOlderPages ? retainedOlder.concat(mapped) : mapped;
3412
+ var page1: ChatMessage[] = mapped;
3413
+ if (interleave.length) {
3414
+ // Both sides are id-sorted oldest-first (retention preserves the
3415
+ // on-screen order; the mapper emits oldest-first).
3416
+ var mergedP: ChatMessage[] = [];
3417
+ var ii2 = 0, mi2 = 0;
3418
+ while (ii2 < interleave.length && mi2 < mapped.length) {
3419
+ var iv: any = interleave[ii2], mv: any = mapped[mi2];
3420
+ var mid2 = typeof mv._serverItemId === 'string' ? mv._serverItemId : undefined;
3421
+ if (mid2 === undefined) break; // defensive: id-less tail
3422
+ if ((iv._serverItemId as string) <= mid2) { mergedP.push(iv); ii2++; }
3423
+ else { mergedP.push(mv); mi2++; }
3424
+ }
3425
+ while (ii2 < interleave.length) mergedP.push(interleave[ii2++]);
3426
+ while (mi2 < mapped.length) mergedP.push(mapped[mi2++]);
3427
+ page1 = mergedP;
3428
+ }
3429
+ keptOlderPages = prependOlder.length > 0 || interleave.length > 0;
3430
+ self.state.messages = prependOlder.length ? prependOlder.concat(page1) : page1;
3024
3431
  rescued.forEach(function (m) { self.state.messages.push(m); });
3025
3432
  if (Object.keys(locallyCancelled).length) {
3026
3433
  for (var ci = 0; ci < self.state.messages.length; ci++) {
@@ -3060,9 +3467,16 @@ export class ChatSession {
3060
3467
  self.state.historyEndOfList = !!(history && history.endOfList);
3061
3468
  self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
3062
3469
  var clearedAt = self.host.getClearedAt();
3063
- if (clearedAt && chatList.length > 0) {
3064
- var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
3065
- if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
3470
+ if (clearedAt) {
3471
+ // SURFACE items only: page 1 also carries bg stubs reaching far
3472
+ // past the horizon, and reading their age as "the frontier is
3473
+ // pre-horizon" falsely ended the pager with post-horizon surface
3474
+ // pages still unfetched.
3475
+ var surfaceItems = chatList.filter(function (it: any) { return !(it && it._fromBgChain); });
3476
+ if (surfaceItems.length > 0) {
3477
+ var oldestUpdated = Number(surfaceItems[surfaceItems.length - 1] && surfaceItems[surfaceItems.length - 1].updated);
3478
+ if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
3479
+ }
3066
3480
  }
3067
3481
  }
3068
3482
  // Clear loading flags BEFORE this render so the final paint is
@@ -3071,6 +3485,96 @@ export class ChatSession {
3071
3485
  self.updateHistoryCache();
3072
3486
  self.host.notify();
3073
3487
 
3488
+ // ── deferred indexing-stub batch (first-paint split) ──────────────
3489
+ // The conversation is already on screen; when the stub batch lands it
3490
+ // is classified/mapped like any page and STRIP-THEN-MERGED: existing
3491
+ // copies of incoming ids are removed first (retained rows may sit at
3492
+ // provisional positions), then the batch merges by timestamp.
3493
+ var bgPending = !fetchMore && history && (history as any).bgPending;
3494
+ if (bgPending) {
3495
+ // Ownership id for the shared loading flag: a same-token refetch can
3496
+ // mint a SECOND bgPending, and the older handler settling later must
3497
+ // not clear (or keep) the flag the newer batch owns.
3498
+ var batchId = ++_bgHistoryBatchSeq;
3499
+ // SILENT unless this is the chat's genuine FIRST PAINT. Two quiet
3500
+ // cases: an ended chain's head refresh (endOfList already true),
3501
+ // and a MID-WALK tab return — the walk restarts for cursor
3502
+ // safety, but the conversation is already on screen and flashing
3503
+ // "Loading indexing history" over it on every return was the
3504
+ // reported bug. Only a never-walked chat announces the batch.
3505
+ if (history.endOfList !== true && (history as any).firstLoad === true) {
3506
+ self.state.bgHistoryLoading = true;
3507
+ self.host.notify();
3508
+ }
3509
+ var releaseBgFlag = function () {
3510
+ if (_bgHistoryBatchSeq === batchId) self.state.bgHistoryLoading = false;
3511
+ };
3512
+ (bgPending as Promise<any>).then(function (batch: any) {
3513
+ if (token !== self.state.gateRefreshToken) { releaseBgFlag(); return; }
3514
+ var bList: any[] = (batch && Array.isArray(batch.list)) ? batch.list : [];
3515
+ bList.forEach(function (item: any) {
3516
+ if (isBgIndexingQueue(item.queue_name)) {
3517
+ var t = item.compact ? item.request_text : extractLastUserTextFromRequest(item.request_body);
3518
+ if (isIndexingRequestText(t)) item._isBgTask = true;
3519
+ else item._isOnBgQueue = true;
3520
+ }
3521
+ });
3522
+ var sorted = bList.sort(function (a: any, b: any) {
3523
+ var ai = typeof a.id === 'string' ? a.id : '', bi = typeof b.id === 'string' ? b.id : '';
3524
+ return ai > bi ? -1 : (ai < bi ? 1 : 0);
3525
+ });
3526
+ var m2 = mapHistoryListToMessages(sorted, platform, {
3527
+ clearedAt: self.host.getClearedAt(),
3528
+ projectId: id.projectId,
3529
+ formatIndexingLabel: self.host.formatIndexingLabel,
3530
+ }).messages;
3531
+ self.applyHydratedBodies(m2);
3532
+ if (keptScreenAwaitingBg && !m2.length && batch && batch.endOfList === true) {
3533
+ // The empty page 1 was kept ONLY on this batch's word — and it
3534
+ // just proved the chat empty server-side (an emptied/cleared
3535
+ // transcript). Let the kept screen go: drop this chat's settled
3536
+ // server bubbles, keep staged and in-flight local work and any
3537
+ // other chat's strays for their own reconcilers, and remember
3538
+ // the end. Without this the stale transcript was indelible and
3539
+ // even re-persisted itself into the history cache.
3540
+ self.state.messages = self.state.messages.filter(function (m: any) {
3541
+ if (typeof m._serverItemId !== 'string') return true;
3542
+ if (m._ownerKey !== undefined && m._ownerKey !== loadKey) return true;
3543
+ return false;
3544
+ });
3545
+ self.state.historyEndOfList = true;
3546
+ releaseBgFlag();
3547
+ self.updateHistoryCache();
3548
+ self.host.notify();
3549
+ return;
3550
+ }
3551
+ var incoming: any = {};
3552
+ m2.forEach(function (m: any) { if (m._serverItemId) incoming[m._serverItemId + '|' + m.role] = true; });
3553
+ var baseList = self.state.messages.filter(function (m: any) { return !(m._serverItemId && incoming[m._serverItemId + '|' + m.role]); });
3554
+ var mergedList: ChatMessage[] = [];
3555
+ var pi2 = 0, ei2 = 0;
3556
+ while (pi2 < m2.length && ei2 < baseList.length) {
3557
+ var pm2: any = m2[pi2], em2: any = baseList[ei2];
3558
+ var eid2 = em2._serverItemId;
3559
+ if (typeof eid2 !== 'string') break; // staged/local tail
3560
+ var pid2 = pm2._serverItemId;
3561
+ if (typeof pid2 !== 'string' || pid2 <= eid2) { mergedList.push(pm2); pi2++; }
3562
+ else { mergedList.push(em2); ei2++; }
3563
+ }
3564
+ while (pi2 < m2.length) mergedList.push(m2[pi2++]);
3565
+ while (ei2 < baseList.length) mergedList.push(baseList[ei2++]);
3566
+ self.state.messages = mergedList;
3567
+ if (batch && batch.endOfList === true) self.state.historyEndOfList = true;
3568
+ releaseBgFlag();
3569
+ self.updateHistoryCache();
3570
+ self.host.notify();
3571
+ }, function () {
3572
+ // Stubs missing until the next refresh re-asks; never fatal.
3573
+ releaseBgFlag();
3574
+ self.host.notify();
3575
+ });
3576
+ }
3577
+
3074
3578
  if (!fetchMore) {
3075
3579
  // Ration BACKGROUND polls here exactly as the drain does (see
3076
3580
  // MAX_CONCURRENT_BG_POLLS); foreground items — a reply the user is
@@ -3211,7 +3715,12 @@ export class ChatSession {
3211
3715
  uploadSingleAttachment(att: any, stageId?: string): Promise<Array<{ name: string; url: string; storagePath: string }>> {
3212
3716
  var self = this;
3213
3717
  var id = this.host.getIdentity();
3214
- att.status = 'uploading'; att.progress = 0; att.errorMessage = '';
3718
+ // progress stays null until the first byte-progress event. Every upload
3719
+ // opens with a get-signed-url round trip that reports nothing, so a chip
3720
+ // initialised to 0 rendered an empty, motionless bar for the whole
3721
+ // preparing phase and read as hung. null is the signal for "preparing"
3722
+ // (barber pole); a number means bytes are actually moving (real bar).
3723
+ att.status = 'uploading'; att.progress = null; att.errorMessage = '';
3215
3724
  att.errorCode = ''; att.errorDetail = ''; // clear any prior failure (retry)
3216
3725
  this.host.renderAttachmentChips();
3217
3726
  var members = (att.kind === 'folder')