bunnyquery 1.8.13 → 1.8.15

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.
@@ -46,7 +46,7 @@ import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, ge
46
46
  import { buildBoundedChatMessages } from './budget';
47
47
  import { createInlineLinkRegex, sanitizeAttachmentLinksForHistory } from './links';
48
48
  import { markImagePreviewStale } from './image_preview';
49
- import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory } from './history';
49
+ import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage } from './history';
50
50
  import { wallClockNow } from './time';
51
51
  import { parseAttachmentContent } from './attachment_parsers';
52
52
  import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
@@ -87,6 +87,13 @@ const WORKER_PASS_ADOPT_ATTEMPTS = [0, 2000, 6000];
87
87
  // asking often (an indexing pass takes tens of seconds, and a big file can hold
88
88
  // the queue for many minutes), but once the queue looks empty the confirming
89
89
  // look is all that stands between the user and their answer.
90
+ // Early point-lookups for a foreground reply, before the flat POLL_INTERVAL tick lands.
91
+ // Widening on purpose: the first two catch a short reply (a greeting, a one-line answer) that
92
+ // would otherwise idle until 3s, and after ~1.7s a reply is long enough that the 3s interval is
93
+ // no longer the dominant cost, so probing further would just add requests. Three probes is at
94
+ // most three extra point lookups per turn, and none of them fire once the reply has landed.
95
+ const EARLY_PROBE_SCHEDULE_MS = [400, 900, 1700];
96
+
90
97
  const INDEXING_DRAIN_BUSY_POLL_MS = 8000;
91
98
  const INDEXING_DRAIN_CONFIRM_POLL_MS = 3000;
92
99
  const INDEXING_DRAIN_IDLE_LOOKS = 2;
@@ -595,7 +602,96 @@ export class ChatSession {
595
602
  *
596
603
  * `stop` comes from the SDK and may be absent on an older skapi-js, in which case the
597
604
  * poll simply cannot be stopped and is left running — see pausePolling.
605
+ *
606
+ * (This block documents _trackPoll, further down. The two methods below sit between it
607
+ * and its subject.)
598
608
  */
609
+ /**
610
+ * Foreground poll with an early-probe race.
611
+ *
612
+ * skapi's poll() is a bare setInterval(fn, latency) with NO check at t=0, so the earliest a
613
+ * reply can be observed is one full POLL_INTERVAL (3s) after dispatch. For a long generation
614
+ * that granularity is free. For a SHORT one it is nearly pure dead time: a greeting that the
615
+ * provider finishes in 1s still waits until the 3s tick, which measured as a large share of a
616
+ * 5s "yo" round trip.
617
+ *
618
+ * So keep the 3s interval as the steady state, and additionally point-look-up the item a few
619
+ * times early, on a widening schedule. Whichever answers first wins and the other is stopped.
620
+ * The probe uses the csrHistoryItemLookup hook both clients already implement; without it this
621
+ * degrades to exactly the old behaviour.
622
+ *
623
+ * FOREGROUND ONLY. Background indexing polls keep the flat cadence: nobody is watching them,
624
+ * and they are the ones bounded by MAX_CONCURRENT_BG_POLLS, so adding probes there would spend
625
+ * the request budget the cap exists to protect.
626
+ */
627
+ attachForegroundPoll(source: any, itemId: string, opts?: any): any {
628
+ return this._fgPollWithEarlyProbe(source, itemId, opts);
629
+ }
630
+
631
+ private _fgPollWithEarlyProbe(source: any, itemId: string, opts?: any): any {
632
+ var self = this;
633
+ // Callbacks ride along on the interval path exactly as before; the probe path below
634
+ // fires onResponse itself, because a caller that only reacts through the callback
635
+ // (the history drain does) would otherwise never learn the probe won.
636
+ var base = source.poll(Object.assign({ latency: POLL_INTERVAL }, opts || {}));
637
+
638
+ var lookup = chatEngineConfig().csrHistoryItemLookup;
639
+ var ident = this.host.getIdentity();
640
+ var platform = ident && ident.platform;
641
+ if (!lookup || !itemId || !ident || !ident.projectId
642
+ || (platform !== 'claude' && platform !== 'openai')) {
643
+ return base;
644
+ }
645
+
646
+ var settled = false;
647
+ var timers: any[] = [];
648
+ var clearProbes = function () {
649
+ for (var i = 0; i < timers.length; i++) clearTimeout(timers[i]);
650
+ timers = [];
651
+ };
652
+ var stopBase = base && typeof base.stop === 'function' ? base.stop.bind(base) : null;
653
+
654
+ var raced: any = new Promise(function (resolve, reject) {
655
+ base.then(function (res: any) {
656
+ if (settled) return;
657
+ settled = true; clearProbes(); resolve(res);
658
+ }, function (err: any) {
659
+ if (settled) return;
660
+ settled = true; clearProbes(); reject(err);
661
+ });
662
+
663
+ var fullId = buildHistoryItemFullId(platform as 'claude' | 'openai', ident.projectId, itemId);
664
+ EARLY_PROBE_SCHEDULE_MS.forEach(function (delay) {
665
+ timers.push(setTimeout(function () {
666
+ if (settled) return;
667
+ Promise.resolve(lookup!(fullId, ident.projectId, ident.owner)).then(function (body: any) {
668
+ if (settled || !body || typeof body !== 'object') return;
669
+ // Still working: leave it to the next probe or the interval.
670
+ if (body.status === 'pending' || body.status === 'running') return;
671
+ // A stop is not a result; the normal stop path handles that.
672
+ if (isPollStopped(body)) return;
673
+ settled = true;
674
+ clearProbes();
675
+ // The interval would otherwise keep firing against a finished item.
676
+ if (stopBase) { try { stopBase(); } catch (e) { /* already gone */ } }
677
+ if (opts && typeof opts.onResponse === 'function') {
678
+ try { opts.onResponse(body); } catch (e) { /* caller's problem, not the poll's */ }
679
+ }
680
+ resolve(body);
681
+ }, function () { /* probe failures are not errors: the interval is the source of truth */ });
682
+ }, delay));
683
+ });
684
+ });
685
+
686
+ // _trackPoll and the cancel path both reach for .stop, so the wrapper has to carry it.
687
+ raced.stop = function () {
688
+ settled = true;
689
+ clearProbes();
690
+ if (stopBase) stopBase();
691
+ };
692
+ return raced;
693
+ }
694
+
599
695
  private _trackPoll(id: string, kind: 'fg' | 'bg', p: any): any {
600
696
  var stop = p && typeof p.stop === 'function' ? p.stop.bind(p) : undefined;
601
697
  if (!stop) {
@@ -816,6 +912,73 @@ export class ChatSession {
816
912
  };
817
913
  }
818
914
 
915
+ /**
916
+ * Give the immediate-send pair the server's id for their turn, the moment the
917
+ * dispatch learns it.
918
+ *
919
+ * WHY THIS EXISTS. An immediate send pushes its user bubble and its
920
+ * "Thinking..." placeholder locally, and until now neither ever carried a
921
+ * _serverItemId — only the QUEUED path stamped one, off its ack. So for the
922
+ * whole life of the turn there was no way to tell the local copy and the
923
+ * server's copy of the SAME turn apart, and the history merge fell back to a
924
+ * heuristic: rescue the local pair unless the freshly-fetched page happens to
925
+ * contain a pending assistant.
926
+ *
927
+ * That heuristic has a hole exactly one poll interval wide. The server settles
928
+ * the request; for up to POLL_INTERVAL the client has not noticed, so
929
+ * `state.sending` is still true and the local pair is still on screen — while a
930
+ * history fetch issued in that window returns the turn ALREADY SETTLED, with no
931
+ * pending assistant in it. The rescue then re-appends the local pair below the
932
+ * server's copy (the question, twice), and when the poll finally resolves,
933
+ * typewriteLatestReply writes the answer into the rescued placeholder because it
934
+ * is the only pending assistant left (the answer, twice). updateHistoryCache
935
+ * persists the result, so it survives every later visit.
936
+ *
937
+ * Navigating away while waiting and coming back is what lands a fetch in that
938
+ * window: a remount runs refreshGate -> a fresh first page, at an arbitrary
939
+ * moment relative to the 3s poll.
940
+ *
941
+ * With the id on the bubbles, both clients' rescue loops skip them through the
942
+ * dedup they already have (`_serverItemId is in this page`), the reply the
943
+ * dispatch caches inherits the id too, and nothing needs a new special case.
944
+ *
945
+ * Matched by _localId, never by index: a file's indexing rows are spliced in
946
+ * above these bubbles while the request is in flight.
947
+ */
948
+ private _stampTurnWithItemId(key: string, userLid: string | undefined, placeholderLid: string | undefined, itemId: string): void {
949
+ // placeholderLid is undefined for a QUEUED send: that path has no
950
+ // "Thinking..." bubble of its own until promoteNextQueuedToRunning makes one,
951
+ // and that copies the user bubble's id itself.
952
+ if (!itemId) return;
953
+ var changed = false;
954
+ var stamp = function (list: ChatMessage[]) {
955
+ var hit = false;
956
+ for (var i = 0; i < list.length; i++) {
957
+ var m = list[i];
958
+ if (!m || !m._localId) continue;
959
+ if (m._localId !== userLid && m._localId !== placeholderLid) continue;
960
+ if (m._serverItemId === itemId) continue;
961
+ list[i] = Object.assign({}, m, { _serverItemId: itemId });
962
+ hit = true;
963
+ }
964
+ return hit;
965
+ };
966
+ changed = stamp(this.state.messages);
967
+ // And in the cache, which is what a remount renders BEFORE the fetch lands.
968
+ // A cached copy still missing the id would be rescued by the very merge this
969
+ // is here to satisfy.
970
+ var cached = key ? this.aiChatHistoryCache[key] : undefined;
971
+ if (cached) {
972
+ var msgs = cached.messages.slice();
973
+ if (stamp(msgs)) {
974
+ this.aiChatHistoryCache[key] = {
975
+ messages: msgs, endOfList: cached.endOfList, startKeyHistory: cached.startKeyHistory,
976
+ };
977
+ }
978
+ }
979
+ if (changed) this.host.notify();
980
+ }
981
+
819
982
  /**
820
983
  * Land a resolved reply in the history cache of a chat that is NOT currently
821
984
  * visible, without touching state.messages. Mirrors the cache-only path in
@@ -918,8 +1081,12 @@ export class ChatSession {
918
1081
  if (initial.id) {
919
1082
  if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
920
1083
  dispatchItemId = initial.id;
1084
+ // Hand the id to the caller so the bubbles already on screen can
1085
+ // carry it. An auth-refresh retry re-enters here with a NEW id,
1086
+ // so this reports every time, not once.
1087
+ if (typeof params.onItemId === 'function') params.onItemId(initial.id);
921
1088
  }
922
- var dp = initial.poll({ latency: POLL_INTERVAL });
1089
+ var dp = self._fgPollWithEarlyProbe(initial, initial.id);
923
1090
  if (initial.id) self._trackPoll(initial.id, 'fg', dp);
924
1091
  return dp;
925
1092
  }
@@ -954,38 +1121,30 @@ export class ChatSession {
954
1121
  // if the chatbox unmounted mid-request.
955
1122
  delete self.pendingAgentRequests[params.key];
956
1123
  if (dispatchItemId) self.historyItemPolls.delete(dispatchItemId);
957
- var existing = self.aiChatHistoryCache[params.key] || { messages: [], endOfList: false, startKeyHistory: [] };
958
1124
  var reply: ChatMessage = { role: 'assistant', content: result.content, isError: result.isError };
1125
+ if (dispatchItemId) reply._serverItemId = dispatchItemId;
959
1126
 
960
1127
  // REPLACE the trailing pending "Thinking..." bubble in the cache with
961
- // the answer (falling back to append when none is present), regardless
962
- // of whether this chat is the currently-visible view. The cache must
963
- // NEVER retain a pending "Thinking..." bubble: even when the chatbox is
964
- // showing this chat, typewriteLatestReply swaps the bubble only in
965
- // state.messages and NEVER re-snapshots the cache so appending a
966
- // duplicate here would leave the cached copy stuck pending, and a later
967
- // cache-first remount (agent.vue's loadChatHistory) would re-render that
968
- // "Thinking..." forever. typewriteLatestReply still finds this reply (it
969
- // reads the latest non-pending assistant from the cache), so replacing is
970
- // correct for the visible case too. The next fresh history fetch
971
- // reconciles it either way.
972
- var msgs = existing.messages.slice();
973
- var idx = -1;
974
- for (var i = msgs.length - 1; i >= 0; i--) {
975
- var m = msgs[i];
976
- if (m && m.isPending && m.role === 'assistant' && !m.isBackgroundTask) { idx = i; break; }
977
- }
978
- if (idx !== -1) {
979
- reply._serverItemId = msgs[idx]._serverItemId;
980
- msgs[idx] = reply;
981
- } else {
982
- msgs.push(reply);
983
- }
984
- self.aiChatHistoryCache[params.key] = {
985
- messages: msgs,
986
- endOfList: existing.endOfList,
987
- startKeyHistory: existing.startKeyHistory,
988
- };
1128
+ // the answer, regardless of whether this chat is the currently-visible
1129
+ // view. The cache must NEVER retain a pending "Thinking..." bubble: even
1130
+ // when the chatbox is showing this chat, typewriteLatestReply swaps the
1131
+ // bubble only in state.messages and NEVER re-snapshots the cache so
1132
+ // leaving a pending copy here would have a later cache-first remount
1133
+ // re-render that "Thinking..." forever. typewriteLatestReply still finds
1134
+ // this reply (it reads the latest non-pending assistant from the cache),
1135
+ // so replacing is correct for the visible case too.
1136
+ //
1137
+ // _applyReplyToCache rather than an inline copy of it. This used to be
1138
+ // its own loop with the same replace-or-append shape MINUS the
1139
+ // dedup-before-append its twin already had, and the append is reachable:
1140
+ // once the local pair carries its server id (_stampTurnWithItemId), a
1141
+ // first-page fetch that lands after the server settles the item
1142
+ // correctly drops both local copies and re-snapshots a cache with no
1143
+ // pending placeholder left to replace. The blind push then wrote the
1144
+ // answer in a SECOND time, with no id on it, and the cache is rendered
1145
+ // verbatim on the next mount — so the fix that stopped the turn
1146
+ // duplicating on screen would have moved the duplicate into the cache.
1147
+ self._applyReplyToCache(params.key, reply, dispatchItemId);
989
1148
  return result;
990
1149
  });
991
1150
  this.pendingAgentRequests[params.key] = run;
@@ -1416,9 +1575,10 @@ export class ChatSession {
1416
1575
  } else {
1417
1576
  this.state.messages.push(queuedBubble);
1418
1577
  }
1419
- this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
1578
+ this.host.notify(); this.updateHistoryCache(); this.scrollForDispatch(stageId);
1420
1579
 
1421
1580
  var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
1581
+ var capturedQueuedLid = queuedBubble._localId;
1422
1582
  Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.projectId, id.owner))
1423
1583
  .then(function (result: any) {
1424
1584
  // Only ack a bubble that belongs to THIS chat — the search is
@@ -1438,10 +1598,19 @@ export class ChatSession {
1438
1598
  if (serverId) upd._serverItemId = serverId;
1439
1599
  self.state.messages[sendingIdx] = upd; self.host.notify();
1440
1600
  }
1601
+ // And into the CACHE, which the block above never touched: a remount
1602
+ // renders the cache first, so an unstamped copy there is a bubble the
1603
+ // first-page merge cannot recognise as the turn the server just sent
1604
+ // back — which is how a queued turn came back doubled. Keyed to
1605
+ // capturedKey and matched by _localId, so it lands correctly even
1606
+ // when the user has already moved to another project (plain
1607
+ // updateHistoryCache writes under the LIVE key, which by then is a
1608
+ // different chat).
1609
+ if (serverId) self._stampTurnWithItemId(capturedKey, capturedQueuedLid, undefined, serverId);
1441
1610
  if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
1442
1611
  // Track this queued item's poll so a remount/refetch dedups
1443
1612
  // against it instead of attaching a duplicate history poll.
1444
- var qp = result.poll({ latency: POLL_INTERVAL });
1613
+ var qp = self._fgPollWithEarlyProbe(result, serverId);
1445
1614
  if (serverId) self._trackPoll(serverId, 'fg', qp);
1446
1615
  return qp
1447
1616
  .then(function (res: any) { if (isPollStopped(res)) return; return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId, capturedKey); })
@@ -1463,7 +1632,10 @@ export class ChatSession {
1463
1632
  // indexing row spliced in above renumbers it — and a view that keys an id-less
1464
1633
  // bubble by index would tear it down and rebuild it on every uploaded file.
1465
1634
  var immediateUser: ChatMessage = { role: 'user', content: composed, _localId: this._newLocalId(), _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) };
1466
- var immediatePlaceholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) };
1635
+ // _localId on the placeholder too. It is how the server item id finds these
1636
+ // two bubbles again once the dispatch reports it (see onItemId below), and
1637
+ // indexing rows spliced in above move both of them, so an index will not do.
1638
+ var immediatePlaceholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _localId: this._newLocalId(), ...(key ? { _ownerKey: key } : {}) };
1467
1639
  var iStage = this._stageIndex(this.state.messages, stageId);
1468
1640
  if (iStage !== -1) {
1469
1641
  // Replace IN PLACE — see the queued branch above for why nothing moves.
@@ -1478,7 +1650,7 @@ export class ChatSession {
1478
1650
  this.state.messages.push(immediateUser);
1479
1651
  this.state.messages.push(immediatePlaceholder);
1480
1652
  }
1481
- this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
1653
+ this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.scrollForDispatch(stageId);
1482
1654
 
1483
1655
  // Same filter as the offChat and isQueuedSend paths above. It must drop the
1484
1656
  // pending flags too: the `isPending` placeholder pushed two lines up is the
@@ -1515,10 +1687,17 @@ export class ChatSession {
1515
1687
  platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
1516
1688
  history: historyForLlm,
1517
1689
  });
1690
+ var immediateUserLid = immediateUser._localId, immediatePlaceholderLid = immediatePlaceholder._localId;
1518
1691
  var run = this.dispatchAgentRequest({
1519
1692
  key: key, projectId: id.projectId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
1520
1693
  systemPrompt: systemPrompt, text: composed, boundedMessages: bounded.messages, userId: chatQueue,
1521
1694
  extractContent: extractContent, fileUrls: fileUrls,
1695
+ // THE fix for a turn rendering twice after navigate-away-and-back. Until
1696
+ // this existed the immediate-send pair carried no server id for its whole
1697
+ // life (only the QUEUED path stamped one, off its ack), so nothing could
1698
+ // tell the local copy and the server's copy of the same turn apart. See
1699
+ // _stampTurnWithItemId.
1700
+ onItemId: function (itemId: string) { self._stampTurnWithItemId(key, immediateUserLid, immediatePlaceholderLid, itemId); },
1522
1701
  });
1523
1702
  // Render the reply into the "Thinking..." bubble whenever the chatbox is
1524
1703
  // CURRENTLY showing this chat — even after an unmount/remount. The old
@@ -1545,6 +1724,29 @@ export class ChatSession {
1545
1724
  });
1546
1725
  }
1547
1726
 
1727
+ /**
1728
+ * Scroll for a dispatch that is going out NOW, but never for one arriving late.
1729
+ *
1730
+ * A turn with attachments does not dispatch when the user hits Send: it waits out
1731
+ * its uploads and then its whole indexing chain, which is minutes
1732
+ * (awaitIndexingDrained). By then the reader has very often scrolled up into
1733
+ * history to pass the time, and the forcing scrollToBottom yanked them out of it
1734
+ * — and worse, force-pinned stickToBottom, which no-ops every method on the
1735
+ * scroll anchor and re-arms the queue-detect poll whose only bail is
1736
+ * !stickToBottom.
1737
+ *
1738
+ * `stageId` is the exact marker for that case: only the attachment path ever
1739
+ * produces one. The gesture itself was already paid for at stage time, where
1740
+ * stageOutgoingMessage forces the scroll while the user is still looking at the
1741
+ * composer. Deliberately NOT gated on "did we find the staged bubble" — a remount
1742
+ * rebuilds from the cache and the turn is appended rather than replaced, which is
1743
+ * just as late and just as unrequested.
1744
+ */
1745
+ private scrollForDispatch(stageId?: string): void {
1746
+ if (stageId) this.host.scrollToBottomIfSticky(true);
1747
+ else this.host.scrollToBottom(true);
1748
+ }
1749
+
1548
1750
  promoteNextBgQueuedToRunning(): void {
1549
1751
  if (this.state.messages.some(function (m) { return m.isPending && m.role === 'assistant' && m.isBackgroundTask; })) return;
1550
1752
  var nextIdx = this.state.messages.findIndex(function (m) {
@@ -1700,6 +1902,31 @@ export class ChatSession {
1700
1902
  else this.state.messages.push(msg);
1701
1903
  }
1702
1904
 
1905
+ /**
1906
+ * The server's OWN copy of this turn is already on screen.
1907
+ *
1908
+ * A first-page fetch can land between the server settling the item and this
1909
+ * poll's tick, and now that the local bubbles carry the item id
1910
+ * (_stampTurnWithItemId) the rescue correctly drops them and renders the
1911
+ * server's settled pair instead. There is then nothing left to resolve: the
1912
+ * -1 fallback would push the answer in a SECOND time at the bottom of the list,
1913
+ * and the positional fallbacks would hijack some other turn's bubble.
1914
+ *
1915
+ * The USER bubble counts, not just a settled assistant: an item whose answer is
1916
+ * empty produces no assistant bubble at all in the mapper, and that variant
1917
+ * would otherwise still bottom-push "No text response received...". While a turn
1918
+ * is genuinely live its user bubble is always pending (the queued branch sets
1919
+ * isPendingQueued, promoteNextQueuedToRunning sets isPendingInProcess), so this
1920
+ * cannot fire early.
1921
+ */
1922
+ private _turnAlreadyRendered(serverId?: string): boolean {
1923
+ if (!serverId) return false;
1924
+ return this.state.messages.some(function (m) {
1925
+ return m._serverItemId === serverId &&
1926
+ !m.isPending && !m.isPendingQueued && !m.isPendingInProcess;
1927
+ });
1928
+ }
1929
+
1703
1930
  onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string, ownerKey?: string): void {
1704
1931
  if (serverId) this.historyItemPolls.delete(serverId);
1705
1932
  // This turn resolved while a DIFFERENT chat is on screen (the user moved to
@@ -1715,6 +1942,10 @@ export class ChatSession {
1715
1942
  if (serverId) this.cancelledServerIds.delete(serverId);
1716
1943
  return;
1717
1944
  }
1945
+ if (this._turnAlreadyRendered(serverId)) {
1946
+ if (serverId) this.cancelledServerIds.delete(serverId);
1947
+ this.host.notify(); this.updateHistoryCache(); return;
1948
+ }
1718
1949
  var targetIdx = this.resolveQueuedUserBubble(serverId);
1719
1950
  if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
1720
1951
  if (isErrorResponseBody(response)) {
@@ -1757,6 +1988,14 @@ export class ChatSession {
1757
1988
  if (serverId) this.cancelledServerIds.delete(serverId);
1758
1989
  return;
1759
1990
  }
1991
+ // Same reason as onQueuedSendResponse: with the server's settled copy already
1992
+ // on screen there is no pending bubble left that belongs to this turn, and
1993
+ // every fallback below would write onto some other turn's — or push a second
1994
+ // bubble at the bottom.
1995
+ if (this._turnAlreadyRendered(serverId)) {
1996
+ if (serverId) this.cancelledServerIds.delete(serverId);
1997
+ this.host.notify(); this.updateHistoryCache(); return;
1998
+ }
1760
1999
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
1761
2000
  if (isNotExists) {
1762
2001
  var userIdx = serverId
@@ -3324,30 +3563,18 @@ export class ChatSession {
3324
3563
  var mappedHasPendingAssistant = mapped.some(function (m: any) {
3325
3564
  return m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
3326
3565
  });
3566
+ // One rule, shared with agent.vue's own fetchHistoryPage — see
3567
+ // shouldRescueInFlightMessage for what it decides and why.
3327
3568
  var rescued: ChatMessage[] = [];
3328
3569
  for (var ri = 0; ri < self.state.messages.length; ri++) {
3329
3570
  var mm = self.state.messages[ri];
3330
- if (mm.isBackgroundTask) continue;
3331
- // Belongs to a different chat (another project, or another
3332
- // platform on this one) — it must not be carried onto THIS
3333
- // chat's freshly-fetched history.
3334
- if (mm._ownerKey !== undefined && mm._ownerKey !== loadKey) continue;
3335
- if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
3336
- if (!mm._serverItemId) {
3337
- // A staged turn (files still uploading) has no server request
3338
- // yet, so nothing in `mapped` can stand for it — rescue it
3339
- // unconditionally. The mappedHasPendingAssistant skip below is
3340
- // about a turn the server ALREADY has; applying it here would
3341
- // delete the user's message mid-upload whenever some other
3342
- // turn happened to be in flight.
3343
- if (mm._stageId) { rescued.push(mm); continue; }
3344
- if (mappedHasPendingAssistant) continue;
3345
- if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
3346
- else if (self.state.sending && mm.role === 'user') {
3347
- var next = self.state.messages[ri + 1];
3348
- if (next && !next.isBackgroundTask && next.isPending && !next._serverItemId) rescued.push(mm);
3349
- }
3350
- }
3571
+ if (shouldRescueInFlightMessage(mm, {
3572
+ hasServerId: function (sid) { return !!serverIds[sid]; },
3573
+ pageHasPendingAssistant: mappedHasPendingAssistant,
3574
+ sending: !!self.state.sending,
3575
+ next: self.state.messages[ri + 1],
3576
+ loadKey: loadKey,
3577
+ })) rescued.push(mm);
3351
3578
  }
3352
3579
  // PRESERVE already-loaded older pages. `mapped` is only page 1, so a
3353
3580
  // blind replace threw away every page the user had scrolled in. This
@@ -3428,7 +3655,15 @@ export class ChatSession {
3428
3655
  }
3429
3656
  keptOlderPages = prependOlder.length > 0 || interleave.length > 0;
3430
3657
  self.state.messages = prependOlder.length ? prependOlder.concat(page1) : page1;
3431
- rescued.forEach(function (m) { self.state.messages.push(m); });
3658
+ rescued.forEach(function (m) {
3659
+ // Rescue can now carry a bubble that has a server id, and the
3660
+ // retained-older merge above carries ids too. One turn must not
3661
+ // arrive down both routes.
3662
+ if (m._serverItemId && self.state.messages.some(function (x) {
3663
+ return x._serverItemId === m._serverItemId && x.role === m.role;
3664
+ })) return;
3665
+ self.state.messages.push(m);
3666
+ });
3432
3667
  if (Object.keys(locallyCancelled).length) {
3433
3668
  for (var ci = 0; ci < self.state.messages.length; ci++) {
3434
3669
  var c = self.state.messages[ci];
@@ -3568,6 +3803,12 @@ export class ChatSession {
3568
3803
  releaseBgFlag();
3569
3804
  self.updateHistoryCache();
3570
3805
  self.host.notify();
3806
+ // The other half of the refresh. Everything this batch brought in
3807
+ // sits ABOVE or AMONG the newest turn, so a reader pinned to the
3808
+ // bottom was left stranded above it by exactly the batch's height,
3809
+ // with nothing to put them back (every scroll-anchor method no-ops
3810
+ // while pinned, and this handler ended here).
3811
+ if (self.host.settleScroll) self.host.settleScroll();
3571
3812
  }, function () {
3572
3813
  // Stubs missing until the next refresh re-asks; never fatal.
3573
3814
  releaseBgFlag();
@@ -3627,8 +3868,12 @@ export class ChatSession {
3627
3868
  // it up once an attached poll settles and frees a slot.
3628
3869
  if ((item._isBgTask || item._isOnBgQueue) && !bgAllow[item.id]) return;
3629
3870
  var capturedId = item.id;
3630
- var pp = item.poll({
3631
- latency: POLL_INTERVAL,
3871
+ var isBg = !!(item._isBgTask || item._isOnBgQueue);
3872
+ // A foreground item here is a reply the user is still waiting on (a reload or a tab
3873
+ // return re-attaches it), so it gets the same early probe as a fresh send. Background
3874
+ // items keep the flat cadence: nobody is watching, and they are what the
3875
+ // MAX_CONCURRENT_BG_POLLS budget exists to protect.
3876
+ var pollOpts = {
3632
3877
  onResponse: function (response: any) { if (isPollStopped(response)) return; self.handleHistoryItemResolution(capturedId, response, platform); },
3633
3878
  onError: function (err: any) {
3634
3879
  self.historyItemPolls.delete(capturedId);
@@ -3669,7 +3914,10 @@ export class ChatSession {
3669
3914
  self.host.notify(); self.updateHistoryCache();
3670
3915
  }
3671
3916
  },
3672
- });
3917
+ };
3918
+ var pp = isBg
3919
+ ? item.poll(Object.assign({ latency: POLL_INTERVAL }, pollOpts))
3920
+ : self._fgPollWithEarlyProbe(item, capturedId, pollOpts);
3673
3921
  // Anything on the BACKGROUND queue is pausable, not just items whose
3674
3922
  // prompt text we recognise as an indexing task. _isBgTask vs
3675
3923
  // _isOnBgQueue is a DISPLAY distinction ("Indexing: file" vs a normal
@@ -3690,7 +3938,11 @@ export class ChatSession {
3690
3938
  // the one resumePolling fires on visibilitychange. Forcing yanked a
3691
3939
  // reader who had scrolled up back to the bottom. On a genuine mount the
3692
3940
  // user is still pinned, so this behaves identically there.
3693
- if (!fetchMore) return self.host.scrollToBottomIfSticky();
3941
+ //
3942
+ // settleScroll when the host has one: it makes the same decision, and it
3943
+ // is also called again once the deferred bg batch merges, so a pinned
3944
+ // reader lands on the REAL bottom instead of the surface page's.
3945
+ if (!fetchMore) return self.host.settleScroll ? self.host.settleScroll() : self.host.scrollToBottomIfSticky();
3694
3946
  }).catch(function (err: any) {
3695
3947
  console.warn('[chat-engine] getChatHistory failed', err);
3696
3948
  }).then(function () {
package/src/widget.css CHANGED
@@ -645,6 +645,14 @@
645
645
  position: relative;
646
646
  overflow-y: auto;
647
647
  overflow-x: hidden;
648
+ /* Exactly ONE compensator. Blink and Gecko run their own scroll anchoring on
649
+ this scroller, and it fires at the very layout the JS anchor's absorb()
650
+ forces when it reads offsetHeight - so an image growing above the fold was
651
+ corrected by the browser and then paid for a second time in JS. Measured on
652
+ one background re-render: 6224px of jump with both live, 0 with this.
653
+ Deleting absorb() instead is not an option (15000px of drift on WebKit,
654
+ which has no native anchoring at all). See engine/scroll_anchor.ts. */
655
+ overflow-anchor: none;
648
656
  flex: 1;
649
657
  min-height: 0;
650
658
  padding: 0.25rem 1.25rem;