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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.8.13",
3
+ "version": "1.8.15",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -32,7 +32,8 @@
32
32
  "scripts": {
33
33
  "build": "tsup && node scripts/build-css.mjs",
34
34
  "build:css": "node scripts/build-css.mjs",
35
- "dev": "npx bns port=3333"
35
+ "dev": "npx bns port=3333",
36
+ "test": "for f in ./tests/*.cjs; do echo \"--- $f\"; node \"$f\" || exit 1; done"
36
37
  },
37
38
  "dependencies": {
38
39
  "basic-node-server": "^1.1.1"
@@ -7,6 +7,7 @@
7
7
  import { extractClaudeText, extractOpenAIText, INDEXING_COMPLETE_MARKER, EMPTY_INDEXING_REPLY, getChatHistory, bgIndexingQueueName } from './requests';
8
8
  import { isErrorResponseBody, getErrorMessage } from './errors';
9
9
  import { sanitizeAttachmentLinksForHistory } from './links';
10
+ import type { ChatMessage } from './host';
10
11
 
11
12
  export function filterListByClearHorizon(list: any[], clearedAt: number): any[] {
12
13
  if (!clearedAt) return list;
@@ -732,3 +733,66 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
732
733
  }
733
734
  return { messages: mapped, runningItemIds: runningItemIds };
734
735
  }
736
+
737
+ /* ---- rescuing in-flight bubbles across a first-page refetch ---------------
738
+ *
739
+ * A first-page fetch REPLACES the message list, and the list may hold bubbles the
740
+ * server does not know about yet: a turn whose request is still in flight, a
741
+ * staged turn whose files are still uploading, a queued turn waiting for its ack.
742
+ * Those have to survive the replace, and nothing else may.
743
+ *
744
+ * The rule lived twice — agent.vue's own fetchHistoryPage and the engine's
745
+ * loadHistory — and the two had to be edited in lockstep to stay honest. It is one
746
+ * function now, because the failure mode when they drift is a turn rendered twice
747
+ * and then PERSISTED into the history cache, where it survives every later visit.
748
+ */
749
+
750
+ export interface RescueDecisionContext {
751
+ /** Is this `_serverItemId` in the page that was just fetched? */
752
+ hasServerId: (id: string) => boolean;
753
+ /**
754
+ * The fetched page already shows a non-background pending assistant.
755
+ *
756
+ * Only meaningful for a bubble with NO server id, where it is the sole
757
+ * available answer to "is this turn already represented?". For a bubble that
758
+ * HAS one, hasServerId answers exactly the same question exactly, and applying
759
+ * this on top of it would drop an in-flight turn whose server copy simply is
760
+ * not in the page that was fetched.
761
+ */
762
+ pageHasPendingAssistant: boolean;
763
+ /** state.sending: an immediate send is in flight for this chat. */
764
+ sending: boolean;
765
+ /** The bubble directly after this one in the local list. */
766
+ next?: ChatMessage | null;
767
+ /** The chat this fetch is FOR. A bubble stamped for another must not cross. */
768
+ loadKey?: string;
769
+ }
770
+
771
+ export function shouldRescueInFlightMessage(m: ChatMessage, ctx: RescueDecisionContext): boolean {
772
+ if (!m) return false;
773
+ // Background indexing bubbles come back through their own merge.
774
+ if (m.isBackgroundTask) return false;
775
+ // Never carry another project's (or another platform's) bubbles onto this chat.
776
+ if (m._ownerKey !== undefined && ctx.loadKey !== undefined && m._ownerKey !== ctx.loadKey) return false;
777
+ // The page carries a fresher copy of this exact turn.
778
+ if (m._serverItemId && ctx.hasServerId(m._serverItemId)) return false;
779
+ // A staged turn has no server request yet, so nothing in the page can stand for
780
+ // it. Unconditional: applying the pending-assistant test here would delete the
781
+ // user's message mid-upload whenever some other turn happened to be in flight.
782
+ if (m._stageId) return true;
783
+ if (!m._serverItemId && ctx.pageHasPendingAssistant) return false;
784
+ // In flight by its own flags, id or no id. The immediate-send pair is stamped
785
+ // with its server id as soon as the dispatch reports one, and that id is there
786
+ // to let the check above recognise the server's copy — not to disqualify the
787
+ // bubble from being kept when there is no such copy.
788
+ if (m.isSendingToServer || m.isPendingQueued || m.isPendingInProcess || m.isPending) return true;
789
+ // An immediate-send user bubble carries no flags of its own (its in-flight-ness
790
+ // lives in state.sending); what identifies it is its own unanswered placeholder
791
+ // directly below it.
792
+ if (ctx.sending && m.role === 'user') {
793
+ var next = ctx.next;
794
+ if (!next || next.isBackgroundTask || !next.isPending) return false;
795
+ return next._serverItemId === undefined || next._serverItemId === m._serverItemId;
796
+ }
797
+ return false;
798
+ }
@@ -194,6 +194,20 @@ export interface ChatHost {
194
194
  * that cannot scroll has no way to reach page 2 (see viewport_fill). Only
195
195
  * the view can measure that, which is why the engine merely announces it. */
196
196
  onHistoryLoaded?(fetchMore: boolean, token: number): void;
197
+ /**
198
+ * A list refresh just changed heights: put the reader back where they were.
199
+ *
200
+ * Called at BOTH moments a first-page refresh moves things — the surface page
201
+ * landing, and the deferred background-indexing batch merging on top of it a
202
+ * round trip later — because leaving a wrong position on screen between the two
203
+ * is what reads as "the scroll jumped, then travelled somewhere else".
204
+ *
205
+ * The view owns the decision (it is the only side that can measure): pinned to
206
+ * the bottom means the bottom AFTER the batch merged, anywhere else means the
207
+ * exact line the reader was on. Falls back to scrollToBottomIfSticky when a host
208
+ * does not implement it, which is the old behaviour.
209
+ */
210
+ settleScroll?(): void;
197
211
 
198
212
  // --- skapi surface beyond configureChatEngine() ---
199
213
  cancelRequest(opts: {
Binary file
@@ -68,6 +68,11 @@ export {
68
68
  // read it the same way or the two will not group together.
69
69
  isIndexingRequestText,
70
70
  parseIndexingRequestText,
71
+ // One rule for which locally-pushed bubbles survive a first-page refetch.
72
+ // Shared because the failure mode when the two clients drift is a turn
73
+ // rendered twice and then persisted into the history cache.
74
+ shouldRescueInFlightMessage,
75
+ type RescueDecisionContext,
71
76
  // One bounded look at the bg-indexing queue: which files still have a live
72
77
  // pass. The dbfile browser's "indexed" badge uses this so a file only goes
73
78
  // green once the run is confirmed over, not when its src:: record appears.
@@ -82,6 +87,18 @@ export {
82
87
  // a box too short to scroll strands the user on page 1 — the normal state once a
83
88
  // page of history collapses into one indexing row. Shared so both chatboxes page
84
89
  // their way out of it identically.
90
+ // Holding the reader's place while the list mutates underneath them. Shared so
91
+ // an older page, an indexing row, a re-parsed chip and a decoded image preview
92
+ // are all absorbed the same way in both chatboxes.
93
+ export {
94
+ createScrollAnchor,
95
+ type ScrollAnchor,
96
+ type ScrollAnchorOptions,
97
+ type RowAnchor,
98
+ type AnchorBoxEl,
99
+ type AnchorRowEl,
100
+ } from './scroll_anchor';
101
+
85
102
  export {
86
103
  fillHistoryViewport,
87
104
  createHistoryFiller,
@@ -113,7 +113,15 @@ export function renderInlineLinkHtml(link: RenderableInlineLink, opts?: InlineLi
113
113
  '<img class="bq-img-preview" alt="' + escapeInlineHtml(full) + '"' +
114
114
  ' data-bq-img-path="' + escapeInlineHtml(link.remotePath || '') + '"' +
115
115
  ' data-bq-img-type="' + escapeInlineHtml(link.image ? link.image.contentType : '') + '"' +
116
- ' loading="lazy" decoding="async">' +
116
+ // decoding="async" but NOT loading="lazy". Lazy guarantees the bytes arrive
117
+ // exactly when the image is near the viewport, which is the one case the
118
+ // scroll anchor deliberately declines to compensate for (growth at or below
119
+ // the fold happened on screen, under a line the reader is looking at) — so it
120
+ // shoved up to 320px of text under their eyes on every scroll toward it. It
121
+ // saved no mint either: hydration mints for every preview in the DOM
122
+ // regardless of viewport. It was also what turned the widget's per-notify
123
+ // teardown into a 13x amplifier (6224px vs 450px measured).
124
+ ' decoding="async">' +
117
125
  // Minting the url is a network round trip before the image even starts
118
126
  // downloading, so the wait is real and needs a state. Inline load, so the
119
127
  // dot trail, never the jumping bunny. CSS hides it the moment the <img>
@@ -656,12 +656,37 @@ export function classifyInlineLink(
656
656
  * the placeholder href), so marking writes one key and the lookup tries all of
657
657
  * them.
658
658
  */
659
+ /**
660
+ * Unicode form is not stable across the places a storage path travels through.
661
+ *
662
+ * macOS hands the browser a DECOMPOSED (NFD) filename, so a Korean name like
663
+ * 운전면허-김대현.jpg arrives as 24 codepoints where the composed (NFC) form is 12.
664
+ * Nothing in this engine normalized either way, so the SAME file could be keyed under
665
+ * two different strings depending on which path it travelled: a mark left by a failed
666
+ * mint under one form would never be cleared by a successful load under the other, and
667
+ * the chip stayed greyed out as "(unavailable)" forever.
668
+ *
669
+ * NFC is the canonical choice: it is what the Unicode standard recommends for
670
+ * interchange, and it is the shorter, more common form on the wire.
671
+ */
672
+ export function canonicalizePathForm(value: string): string {
673
+ if (!value) return value;
674
+ try { return value.normalize('NFC'); } catch (e) { return value; }
675
+ }
676
+
659
677
  export function linkUnavailableKeyForPath(remotePath: string): string {
660
- return 'path:' + (remotePath || '');
678
+ // Canonicalized so the NFC and NFD spellings of one file share ONE key.
679
+ return 'path:' + canonicalizePathForm(remotePath || '');
661
680
  }
662
681
 
663
682
  export function linkUnavailableKeyForHref(href: string): string {
664
- return 'href:' + (href || '');
683
+ // An `_expired_.url` placeholder carries the storage path percent-encoded, so NFC and
684
+ // NFD spellings of one file produce two different href strings and therefore two
685
+ // different keys. Route those through the path key instead, so a file has ONE key
686
+ // however it is spelled and whichever carrier it arrived on.
687
+ var carried = readExpiredAttachmentHref(href);
688
+ if (carried) return linkUnavailableKeyForPath(carried);
689
+ return 'href:' + canonicalizePathForm(href || '');
665
690
  }
666
691
 
667
692
  /**
@@ -676,10 +701,13 @@ export function linkUnavailableKeyForHref(href: string): string {
676
701
  */
677
702
  export function linkUnavailableKeysForPath(remotePath: string): string[] {
678
703
  if (!remotePath) return [];
679
- return [
704
+ var keys = [
680
705
  linkUnavailableKeyForPath(remotePath),
681
706
  linkUnavailableKeyForHref(buildDisplayExpiredAttachmentHref(remotePath)),
682
707
  ];
708
+ // Both now canonicalize to the same key for a placeholder href, so drop the duplicate
709
+ // rather than marking and clearing the same entry twice.
710
+ return keys.filter(function (k, i) { return keys.indexOf(k) === i; });
683
711
  }
684
712
 
685
713
  export function isLinkUnavailable(