bunnyquery 1.8.13 → 1.8.14

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.14",
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
+ }
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(
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Hold the reader's place in the message list.
3
+ *
4
+ * A chat box mutates constantly WITHOUT the reader asking for it: an older page
5
+ * prepends, a poll resolves, an indexing row splices in or changes label, a link
6
+ * chip goes grey, an image preview finishes decoding, the "Fetching history..."
7
+ * bar appears and disappears. Every one of those changes the height of something
8
+ * that may sit ABOVE the viewport, and the browser answers by keeping scrollTop —
9
+ * which slides the sentence the user was reading out from under them.
10
+ *
11
+ * Both clients had their own copy of a row anchor for the ONE case each could
12
+ * bracket (agent.vue watched its row-key list, the widget bracketed its full
13
+ * re-render). Everything else — anything that changed a height without changing
14
+ * the row SET, and everything asynchronous — was uncovered in both. This is the
15
+ * single implementation, and it covers both shapes:
16
+ *
17
+ * preserve(fn) / capture() + restore(a)
18
+ * A mutation you can bracket. Measures immediately before and immediately
19
+ * after, so it is exact even when the mutation tears the list down.
20
+ *
21
+ * remember() + hold()
22
+ * A layout change you CANNOT bracket — an image decoding, a font arriving,
23
+ * a re-parse triggered from a promise. `remember()` runs from the view's
24
+ * scroll handler, so the anchor is always the reader's own last position;
25
+ * `hold()` puts that position back whenever something settles.
26
+ *
27
+ * The staleness rule is what makes the unbracketed half safe. A layout change
28
+ * above the viewport does NOT change scrollTop — the browser preserves it, which
29
+ * is precisely why the content appears to jump. So a remembered anchor is still
30
+ * valid exactly while `box.scrollTop` equals the value it was captured at. If it
31
+ * differs, something moved the box on purpose (the user scrolled, a clamp fired,
32
+ * or the browser's own scroll anchoring already compensated), and `hold()`
33
+ * re-captures rather than dragging the reader back to a position they left.
34
+ *
35
+ * DOM-free like the rest of the engine: the element shapes below are structural,
36
+ * so real DOM nodes satisfy them while this file imports nothing from lib.dom.
37
+ */
38
+
39
+ export interface AnchorRect {
40
+ top: number;
41
+ }
42
+
43
+ export interface AnchorRowEl {
44
+ getAttribute(name: string): string | null;
45
+ getBoundingClientRect(): AnchorRect;
46
+ offsetHeight: number;
47
+ parentNode: unknown;
48
+ }
49
+
50
+ /** Anything inside the list that resizes on its own schedule. See absorb(). */
51
+ export interface AnchorGrowableEl {
52
+ getBoundingClientRect(): AnchorRect;
53
+ offsetHeight: number;
54
+ }
55
+
56
+ export interface AnchorBoxEl {
57
+ children: ArrayLike<AnchorRowEl>;
58
+ getBoundingClientRect(): AnchorRect;
59
+ scrollTop: number;
60
+ scrollHeight: number;
61
+ clientHeight: number;
62
+ }
63
+
64
+ export interface RowAnchor {
65
+ /** data-row-key of the anchored row, or null when nothing was anchorable. */
66
+ key: string | null;
67
+ /** Offset of that row from the top of the viewport. Negative above the fold. */
68
+ top: number;
69
+ /** data-row-pos, present only on rows that can RELOCATE (see below). */
70
+ pos: string | null;
71
+ /** scrollTop at capture time. The staleness check, and the raw fallback. */
72
+ scrollTop: number;
73
+ /**
74
+ * scrollHeight at capture time. How much the list GREW is the best available
75
+ * answer when the anchored row itself cannot be found again, and the bound on
76
+ * how far a correction can legitimately be.
77
+ */
78
+ scrollHeight: number;
79
+ /**
80
+ * The anchored element itself. A view that patches in place (Vue) keeps the
81
+ * same node across an update, so restore is one rect read instead of a scan;
82
+ * a view that rebuilds the list (the widget) drops it and falls back to the
83
+ * key. Never trusted without re-checking that it is still in the box.
84
+ */
85
+ el: AnchorRowEl | null;
86
+ }
87
+
88
+ export interface ScrollAnchorOptions {
89
+ /** The scrolling message box, or null when it is not mounted. */
90
+ getBox: () => AnchorBoxEl | null;
91
+ /**
92
+ * The reader is pinned to the bottom. There the bottom IS the anchor and the
93
+ * scrollToBottom* paths own the position, so every method here no-ops.
94
+ */
95
+ isStuck: () => boolean;
96
+ /**
97
+ * Fall back to the raw scrollTop when the anchored row cannot be found again.
98
+ *
99
+ * For a view that REBUILDS the list (the widget's renderMessages), detaching
100
+ * every child collapses scrollHeight and the browser clamps scrollTop to 0,
101
+ * so the raw offset is strictly better than the clamp it would otherwise be
102
+ * left with. For a view that patches in place (Vue) the browser has already
103
+ * kept a sane position and re-imposing a stale offset is worse than nothing.
104
+ */
105
+ rawFallback?: boolean;
106
+ }
107
+
108
+ export interface ScrollAnchor {
109
+ /** Measure the reader's current place. Null while pinned to the bottom. */
110
+ capture: () => RowAnchor | null;
111
+ /** Put a captured place back. Safe to call with null. */
112
+ restore: (anchor: RowAnchor | null) => void;
113
+ /** capture -> mutate -> restore, for a mutation you can bracket. */
114
+ preserve: <T>(mutate: () => T) => T;
115
+ /** Record the reader's place. Call from the box's scroll handler. */
116
+ remember: () => void;
117
+ /** Put the remembered place back, if it is still the reader's own. */
118
+ hold: () => void;
119
+ /** Absorb one element's own resize. See below. */
120
+ absorb: (el: AnchorGrowableEl | null | undefined) => void;
121
+ /** Drop the remembered place (chat switch, unmount). */
122
+ forget: () => void;
123
+ }
124
+
125
+ /**
126
+ * A row is anchorable when it carries data-row-key. The bars that are not rows —
127
+ * "Fetching history...", the greeting, the drafting bubble, an expanded group's
128
+ * trailing loader — deliberately carry none, so they are never anchored ON while
129
+ * still being fully covered BY the anchor: they change height above a row that
130
+ * is held in place, and holding it is what absorbs them.
131
+ */
132
+ var ROW_KEY_ATTR = 'data-row-key';
133
+ /**
134
+ * A collapsed indexing row names the turn it currently renders at. It is a WEAK
135
+ * anchor because it can RELOCATE — an older page carrying earlier passes of the
136
+ * same run moves the row itself — and pinning a row while it moves is what would
137
+ * drag the reader along with it. So an ordinary message row is always preferred,
138
+ * and a group row is used only when nothing else is on screen, and then only if
139
+ * it did not move.
140
+ */
141
+ var ROW_POS_ATTR = 'data-row-pos';
142
+ /** data-row-pos is present but empty: the row cannot say where it is anchored. */
143
+ var UNKNOWN_ROW_POS = '\u0000?';
144
+
145
+ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
146
+ var held: RowAnchor | null = null;
147
+ // Per-element height, so absorb() needs no "before" call from the caller.
148
+ // Weak on purpose: a re-render throws every row away and a strong map would
149
+ // hold the whole conversation's DOM alive behind it.
150
+ var seen: WeakMap<object, number> | null =
151
+ typeof WeakMap === 'function' ? new WeakMap<object, number>() : null;
152
+
153
+ function capture(): RowAnchor | null {
154
+ var box = options.getBox();
155
+ if (!box || options.isStuck()) return null;
156
+ var boxTop = box.getBoundingClientRect().top;
157
+ var kids = box.children;
158
+ var fallback: RowAnchor | null = null;
159
+ for (var i = 0; i < kids.length; i++) {
160
+ var el = kids[i];
161
+ if (!el || typeof el.getAttribute !== 'function') continue;
162
+ var key = el.getAttribute(ROW_KEY_ATTR);
163
+ if (!key) continue;
164
+ var top = el.getBoundingClientRect().top - boxTop;
165
+ // Rows still (partly) on screen. `top` is negative when a row starts
166
+ // above the fold, which is exactly the offset to preserve.
167
+ if (top + el.offsetHeight <= 0) continue;
168
+ // And STOP at the bottom of the viewport. Without this the preference
169
+ // for an ordinary row walks straight past a screenful of collapsed
170
+ // indexing rows and anchors on a message two screens down — which is
171
+ // not the reader's place, and holds the wrong thing when a row between
172
+ // the two changes height.
173
+ if (top >= box.clientHeight) break;
174
+ // An EMPTY data-row-pos means "this row cannot say where it is anchored
175
+ // yet" — a run:: stub has no anchorId until its real group loads. Treating
176
+ // "" as a position made every stub -> real-group handoff read as a
177
+ // relocation and abort the anchor, which is a background resolution that
178
+ // happens on every fresh open. Empty is normalised to null, which also
179
+ // keeps such a row from being MISTAKEN for an ordinary one: the
180
+ // ordinary/group split is the attribute's PRESENCE, tested first.
181
+ var rawPos = el.getAttribute(ROW_POS_ATTR);
182
+ var pos = rawPos === null ? null : (rawPos || UNKNOWN_ROW_POS);
183
+ var cand: RowAnchor = {
184
+ key: key, top: top, pos: pos,
185
+ scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
186
+ };
187
+ if (rawPos === null) return cand; // an ordinary row: use it
188
+ if (!fallback) fallback = cand; // a group row: only if nothing better
189
+ }
190
+ return fallback || {
191
+ key: null, top: 0, pos: null,
192
+ scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: null,
193
+ };
194
+ }
195
+
196
+ function findRow(box: AnchorBoxEl, anchor: RowAnchor): AnchorRowEl | null {
197
+ // The same node, still in the box: one rect read instead of a scan. Vue
198
+ // patches keyed rows in place, so this is the common path there, and it is
199
+ // what keeps a per-update hold() cheap enough to run on every update.
200
+ var el = anchor.el;
201
+ if (el && el.parentNode === (box as unknown)) return el;
202
+ if (!anchor.key) return null;
203
+ var kids = box.children;
204
+ for (var i = 0; i < kids.length; i++) {
205
+ var kid = kids[i];
206
+ if (!kid || typeof kid.getAttribute !== 'function') continue;
207
+ if (kid.getAttribute(ROW_KEY_ATTR) === anchor.key) return kid;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ function restore(anchor: RowAnchor | null): void {
213
+ var box = options.getBox();
214
+ if (!box || !anchor || options.isStuck()) return;
215
+ var el = findRow(box, anchor);
216
+ if (el) {
217
+ // A row that MOVED (an older page re-anchored a collapsed run to its
218
+ // true first pass) must not be pinned: doing so would drag the reader
219
+ // along with it, to wherever the run now starts.
220
+ // Only compare when BOTH sides actually name a turn. A stub that has since
221
+ // learned its anchorId (or lost it) has not moved; it has just started (or
222
+ // stopped) being able to answer.
223
+ var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
224
+ if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS &&
225
+ livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) {
226
+ lost(box, anchor);
227
+ return;
228
+ }
229
+ var boxTop = box.getBoundingClientRect().top;
230
+ var delta = (el.getBoundingClientRect().top - boxTop) - anchor.top;
231
+ // A row can also be MOVED rather than resized: a background refetch
232
+ // that merges a run's passes into the middle of page 1 relocates the
233
+ // bubble this anchor is holding, and following it would carry the
234
+ // reader across the conversation. A real prepend or in-place growth
235
+ // can only ever need a correction on the order of what the list gained,
236
+ // so a delta a whole screen beyond that is a relocation, not a resize.
237
+ var slack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
238
+ if (delta > slack || delta < -slack) { lost(box, anchor); return; }
239
+ // Sub-pixel noise is not a jump, and writing scrollTop for it costs a
240
+ // scroll event (and a re-layout) on every settle.
241
+ if (delta >= 1 || delta <= -1) box.scrollTop += delta;
242
+ // This position is now the reader's place, and hold() has to know it:
243
+ // a bracketed restore MOVES scrollTop, which is exactly what hold()
244
+ // reads as "someone scrolled, my anchor is stale". Without this, every
245
+ // image that decodes after a re-render (which is all of them: the list
246
+ // is rebuilt with src-less, zero-height previews and hydrated
247
+ // afterwards) would find a stale anchor and go uncompensated.
248
+ held = {
249
+ key: anchor.key, top: anchor.top, pos: anchor.pos,
250
+ scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
251
+ };
252
+ return;
253
+ }
254
+ // The anchor row is gone: its group collapsed, or the history was replaced.
255
+ lost(box, anchor);
256
+ }
257
+
258
+ /**
259
+ * The anchored row cannot be held: it is gone, or it relocated.
260
+ *
261
+ * What is still known is how much the list GREW, and in the case this branch
262
+ * exists for — the pager, whose page can carry the very pass that re-anchors a
263
+ * collapsed row — all of that growth is above the reader. So pay it. Missing it
264
+ * costs the reader a whole page of history in one jump, which is the single
265
+ * most visible version of this bug.
266
+ *
267
+ * With nothing gained there is nothing to pay, and then the two views differ:
268
+ * one REBUILDS the list (its teardown clamped scrollTop to 0, so the raw offset
269
+ * beats the clamp) and one patches in place (the browser already kept a sane
270
+ * position, so re-imposing a stale offset is worse than nothing).
271
+ */
272
+ function lost(box: AnchorBoxEl, anchor: RowAnchor): void {
273
+ held = null;
274
+ var grew = box.scrollHeight - anchor.scrollHeight;
275
+ if (grew > 0) { box.scrollTop = anchor.scrollTop + grew; return; }
276
+ if (options.rawFallback) box.scrollTop = anchor.scrollTop;
277
+ }
278
+
279
+ function preserve<T>(mutate: () => T): T {
280
+ var anchor = capture();
281
+ var result = mutate();
282
+ restore(anchor);
283
+ return result;
284
+ }
285
+
286
+ function remember(): void {
287
+ held = capture();
288
+ }
289
+
290
+ function hold(): void {
291
+ var box = options.getBox();
292
+ if (!box || options.isStuck()) { held = null; return; }
293
+ if (!held) { held = capture(); return; }
294
+ // Something moved the box on purpose since the anchor was taken — the user
295
+ // scrolled, a shrink clamped it, or the browser's own scroll anchoring
296
+ // already compensated. Restoring here would undo a move the reader made or
297
+ // double-count one already made for us, so re-measure instead.
298
+ if (box.scrollTop !== held.scrollTop) { held = capture(); return; }
299
+ // restore() re-stamps `held` with the position it just pinned, so repeated
300
+ // holds (one image after another finishing) each start from a valid anchor.
301
+ restore(held);
302
+ }
303
+
304
+ /**
305
+ * Absorb a resize made by ONE element, wherever it sits.
306
+ *
307
+ * The row anchor cannot see this case. A reader partway through an assistant
308
+ * reply that is taller than the viewport is anchored ON that row, and a
309
+ * picture decoding higher up INSIDE it moves every line they are reading
310
+ * without moving the row's own top by a pixel. Rows above the fold have the
311
+ * same problem in reverse: hold() would fix them, but it cannot be allowed to
312
+ * run for an image as well or the two would each pay the same debt.
313
+ *
314
+ * So images go through here instead, and it is the more precise of the two:
315
+ * it compensates by the element's own height delta, and only while the
316
+ * element's TOP is above the fold — which is exactly the condition for
317
+ * "everything the reader can see just moved by this much". An element that
318
+ * starts at or below the fold is left alone: it grew on screen, under a line
319
+ * the reader is looking at, and moving them is what would be the jump.
320
+ *
321
+ * The height it last saw is remembered per element, so the caller does not
322
+ * have to bracket anything. An element it has never seen counts as zero,
323
+ * which is what an <img> measures before it has anything to paint — including
324
+ * the markdown `![alt](url)` images that have no hydration hook at all.
325
+ */
326
+ function absorb(el: AnchorGrowableEl | null | undefined): void {
327
+ if (!el) return;
328
+ var box = options.getBox();
329
+ if (!box) return;
330
+ var h = el.offsetHeight;
331
+ var prev = seen ? seen.get(el) : undefined;
332
+ if (seen) seen.set(el, h);
333
+ if (options.isStuck()) return;
334
+ if (prev === undefined) prev = 0;
335
+ var delta = h - prev;
336
+ if (delta === 0) return;
337
+ // The element's own TOP, which a resize never moves: everything BELOW it
338
+ // slides by delta, everything above stays. So the reader's first visible
339
+ // line moved exactly when that top is above the fold — whether the element
340
+ // grew, collapsed, or straddles the fold now that it has grown. When the
341
+ // top is at or below the fold the growth happens on screen, at or under a
342
+ // line the reader is looking at, and moving them would be the jump.
343
+ if (el.getBoundingClientRect().top >= box.getBoundingClientRect().top) return;
344
+ box.scrollTop += delta;
345
+ // Re-measure the remembered anchor, do not patch it. Its scrollTop is
346
+ // stale after that write (hold() would read the difference as "the reader
347
+ // scrolled" and throw the anchor away), and so is its offset whenever the
348
+ // element that just resized lives INSIDE the anchored row: there the row's
349
+ // own top never moved, so scrolling by delta changed the row's offset by
350
+ // -delta and the next hold() would faithfully undo this correction.
351
+ if (held) held = capture();
352
+ }
353
+
354
+ function forget(): void {
355
+ held = null;
356
+ }
357
+
358
+ return {
359
+ capture: capture,
360
+ restore: restore,
361
+ preserve: preserve,
362
+ remember: remember,
363
+ hold: hold,
364
+ absorb: absorb,
365
+ forget: forget,
366
+ };
367
+ }