bunnyquery 1.8.14 → 1.8.16

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.14",
3
+ "version": "1.8.16",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -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: {
@@ -83,6 +83,18 @@ export interface RowAnchor {
83
83
  * key. Never trusted without re-checking that it is still in the box.
84
84
  */
85
85
  el: AnchorRowEl | null;
86
+ /**
87
+ * The next few anchorable rows below the primary, each with its own offset.
88
+ *
89
+ * The primary row does not always survive: a refresh can drop it, a collapsed
90
+ * indexing row can be re-identified, an expanded group can fold. Without a
91
+ * fallback the only thing left is lost(), which guesses from the list's total
92
+ * growth — and total growth includes everything added BELOW the reader, so a
93
+ * merge that lands rows on both sides of them over-pays. A second row that is
94
+ * still there beats any guess, and collecting them costs nothing: capture is
95
+ * already walking these rows.
96
+ */
97
+ alts?: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }>;
86
98
  }
87
99
 
88
100
  export interface ScrollAnchorOptions {
@@ -93,6 +105,21 @@ export interface ScrollAnchorOptions {
93
105
  * scrollToBottom* paths own the position, so every method here no-ops.
94
106
  */
95
107
  isStuck: () => boolean;
108
+ /**
109
+ * The reader cannot see this box right now (the tab is hidden), so FREEZE:
110
+ * remember where they were and refuse to move them.
111
+ *
112
+ * A hidden tab still runs everything that mutates the list — a resumed poll, a
113
+ * head refresh and its deferred background batch, a settling request — and each
114
+ * of those would otherwise write scrollTop against a layout nobody is looking at
115
+ * and re-stamp the remembered position on the way through. The reader then comes
116
+ * back to wherever the last of those writes happened to land, which is the
117
+ * "somehow placed in the middle" they see, and only the NEXT correction puts
118
+ * them right. So while frozen, reads still happen but nothing writes and nothing
119
+ * re-stamps: the anchor holds the last position the reader actually had, and one
120
+ * hold() on return puts them back on it.
121
+ */
122
+ isFrozen?: () => boolean;
96
123
  /**
97
124
  * Fall back to the raw scrollTop when the anchored row cannot be found again.
98
125
  *
@@ -116,6 +143,33 @@ export interface ScrollAnchor {
116
143
  remember: () => void;
117
144
  /** Put the remembered place back, if it is still the reader's own. */
118
145
  hold: () => void;
146
+ /** The reader is going away: park the exact place they are leaving. */
147
+ park: () => void;
148
+ /**
149
+ * They are back. Puts them on the parked place, and STAYS ARMED until it has
150
+ * actually landed. Returns true when the host must pin to the bottom instead
151
+ * (the reader left pinned), which only the host can do meaningfully.
152
+ */
153
+ settleReturn: () => boolean;
154
+ /** A return is armed: its position, not the host's, decides scrollTop. */
155
+ isReturning: () => boolean;
156
+ /**
157
+ * The reader is driving. Retire any armed return, at once.
158
+ *
159
+ * Call from the raw gesture handlers (wheel, touchstart, touchmove, keydown),
160
+ * which fire synchronously on a real user action and never for a programmatic
161
+ * scroll. Waiting for the scroll EVENT is not good enough: a background refresh
162
+ * can settle in between, and a settle with a return still armed puts the reader
163
+ * back where they were before they left, which lands as "I touched the scroll
164
+ * and it threw me somewhere else".
165
+ */
166
+ release: () => void;
167
+ /** Pin to the bottom, instantly, recording the write. The ONLY way to pin. */
168
+ pinBottom: () => void;
169
+ /** The box is not being painted (hidden tab). Shared so hosts agree. */
170
+ isFrozen: () => boolean;
171
+ /** Deprecated alias of settleReturn, kept so a stale dist does not break. */
172
+ thaw: () => void;
119
173
  /** Absorb one element's own resize. See below. */
120
174
  absorb: (el: AnchorGrowableEl | null | undefined) => void;
121
175
  /** Drop the remembered place (chat switch, unmount). */
@@ -139,6 +193,14 @@ var ROW_KEY_ATTR = 'data-row-key';
139
193
  * it did not move.
140
194
  */
141
195
  var ROW_POS_ATTR = 'data-row-pos';
196
+ /** How many standby rows capture() records. Two is enough to survive a refresh
197
+ * that drops the reader's own row without paying for a full-list guess. */
198
+ var MAX_ALTS = 2;
199
+ /** How far past the anchor collectAlts will look for them. */
200
+ var ALT_SCAN_LIMIT = 64;
201
+ /** Settles an armed return may act on: the two halves of one head refresh, plus
202
+ * one spare for a refresh that was already in flight when the reader left. */
203
+ var MAX_RETURN_SETTLES = 3;
142
204
  /** data-row-pos is present but empty: the row cannot say where it is anchored. */
143
205
  var UNKNOWN_ROW_POS = '\u0000?';
144
206
 
@@ -156,6 +218,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
156
218
  var boxTop = box.getBoundingClientRect().top;
157
219
  var kids = box.children;
158
220
  var fallback: RowAnchor | null = null;
221
+ var fallbackAt = -1;
159
222
  for (var i = 0; i < kids.length; i++) {
160
223
  var el = kids[i];
161
224
  if (!el || typeof el.getAttribute !== 'function') continue;
@@ -184,15 +247,53 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
184
247
  key: key, top: top, pos: pos,
185
248
  scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
186
249
  };
187
- if (rawPos === null) return cand; // an ordinary row: use it
188
- if (!fallback) fallback = cand; // a group row: only if nothing better
250
+ if (rawPos === null) {
251
+ // An ordinary row: use it, and take a couple of standbys from the rows
252
+ // after it in the same walk.
253
+ cand.alts = collectAlts(box, boxTop, i + 1);
254
+ return cand;
255
+ }
256
+ if (!fallback) { fallback = cand; fallbackAt = i; } // group row: last resort
257
+ }
258
+ // The weak anchor needs standbys MORE than the strong one does, not less: a
259
+ // screenful of collapsed indexing rows is exactly what a background sweep
260
+ // re-keys and re-anchors, and without them state (c) fell straight into
261
+ // lost()'s whole-list guess. Ordinary rows only — more group rows from the
262
+ // same block are the very rows the same batch rewrites.
263
+ if (fallback) {
264
+ fallback.alts = collectAlts(box, boxTop, fallbackAt + 1, true);
265
+ return fallback;
189
266
  }
190
- return fallback || {
267
+ return {
191
268
  key: null, top: 0, pos: null,
192
269
  scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: null,
193
270
  };
194
271
  }
195
272
 
273
+ /** Up to MAX_ALTS anchorable rows starting at `from`, for restore's fallback. */
274
+ function collectAlts(box: AnchorBoxEl, boxTop: number, from: number, ordinaryOnly?: boolean) {
275
+ var out: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }> = [];
276
+ var kids = box.children;
277
+ // Bounded: a chat can carry hundreds of collapsed rows below the fold, and
278
+ // this must not become a full-list walk for two standbys.
279
+ var stop = Math.min(kids.length, from + ALT_SCAN_LIMIT);
280
+ for (var i = from; i < stop && out.length < MAX_ALTS; i++) {
281
+ var el = kids[i];
282
+ if (!el || typeof el.getAttribute !== 'function') continue;
283
+ var key = el.getAttribute(ROW_KEY_ATTR);
284
+ if (!key) continue;
285
+ var rawPos = el.getAttribute(ROW_POS_ATTR);
286
+ if (ordinaryOnly && rawPos !== null) continue;
287
+ out.push({
288
+ key: key,
289
+ top: el.getBoundingClientRect().top - boxTop,
290
+ pos: rawPos === null ? null : (rawPos || UNKNOWN_ROW_POS),
291
+ el: el,
292
+ });
293
+ }
294
+ return out.length ? out : undefined;
295
+ }
296
+
196
297
  function findRow(box: AnchorBoxEl, anchor: RowAnchor): AnchorRowEl | null {
197
298
  // The same node, still in the box: one rect read instead of a scan. Vue
198
299
  // patches keyed rows in place, so this is the common path there, and it is
@@ -209,7 +310,49 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
209
310
  return null;
210
311
  }
211
312
 
212
- function restore(anchor: RowAnchor | null): void {
313
+ // A frozen stretch happened and has not been settled yet. hold() has to know,
314
+ // because its own safety rule cannot survive one: on the first call after the
315
+ // tab comes forward it must put the reader back rather than conclude they moved
316
+ // (and, worse, re-measure — which is what DESTROYS the only record of where they
317
+ // were, and why this cannot be left to whoever calls thaw() first).
318
+ var sawFrozen = false;
319
+ // Where the reader was when they went away. Held apart from `held` because the
320
+ // ordinary compensation keeps re-stamping that one while they are gone.
321
+ var parked: RowAnchor | null = null;
322
+ // They left pinned to the bottom. capture() records nothing for such a reader
323
+ // (there is no row to hold, the bottom IS the place), so this is the only note
324
+ // of it — and without it a stickiness lost during the absence had no fallback.
325
+ var parkedStuck = false;
326
+ // A return is armed: it has not landed yet. It survives across BOTH halves of a
327
+ // head refresh, which is what puts a pinned reader on the bottom that exists
328
+ // after the deferred batch merges rather than the surface page's bottom.
329
+ var returning = false;
330
+ // How many more settles an armed return may act on. A return exists to survive
331
+ // the two halves of one head refresh; beyond that it is a stale instruction
332
+ // lying in wait, and the reader has moved on. Bounded so a blur that never got
333
+ // a matching focus (clicking into devtools, another window, an iframe — all of
334
+ // which fire window blur) cannot arm something that fires minutes later.
335
+ var returnBudget = 0;
336
+ // The last scrollTop THIS module wrote. A scroll event reporting anything else
337
+ // is the reader, and the reader always wins — that is the whole retirement rule
338
+ // for an armed return, and it is why every write below records it.
339
+ var wroteTop = -1;
340
+ // Did the last restore's write actually land, and was it a real row pin rather
341
+ // than lost()'s guess. A shrink below the reader silently truncates a
342
+ // correction, and a return that believes a clamped write succeeded is a reader
343
+ // left a few hundred pixels off their line, permanently.
344
+ var restoreExact = true;
345
+ var restorePinned = false;
346
+ function frozen(): boolean {
347
+ var f = !!options.isFrozen && options.isFrozen();
348
+ if (f) sawFrozen = true;
349
+ return f;
350
+ }
351
+
352
+ function restore(anchor: RowAnchor | null, unbounded?: boolean): void {
353
+ // Frozen: leave `held` exactly as it is. It is the reader's last real
354
+ // position, and it is what hold() puts back when the tab comes forward.
355
+ if (frozen()) return;
213
356
  var box = options.getBox();
214
357
  if (!box || !anchor || options.isStuck()) return;
215
358
  var el = findRow(box, anchor);
@@ -221,11 +364,13 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
221
364
  // learned its anchorId (or lost it) has not moved; it has just started (or
222
365
  // stopped) being able to answer.
223
366
  var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
367
+ // A relocation disqualifies the ROW, not the whole capture: fall through
368
+ // to the standbys, which is a real measurement, rather than to lost()'s
369
+ // whole-list guess.
224
370
  if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS &&
225
- livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) {
226
- lost(box, anchor);
227
- return;
228
- }
371
+ livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) el = null;
372
+ }
373
+ if (el) {
229
374
  var boxTop = box.getBoundingClientRect().top;
230
375
  var delta = (el.getBoundingClientRect().top - boxTop) - anchor.top;
231
376
  // A row can also be MOVED rather than resized: a background refetch
@@ -234,11 +379,19 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
234
379
  // reader across the conversation. A real prepend or in-place growth
235
380
  // can only ever need a correction on the order of what the list gained,
236
381
  // so a delta a whole screen beyond that is a relocation, not a resize.
382
+ // `unbounded` is the thaw: across a hidden stretch the box may have been
383
+ // scrolled anywhere at all (a settling request, a clamp), so a correction
384
+ // the size of the whole list is not evidence that the ROW moved — it is
385
+ // just how far the reader has to be carried back.
237
386
  var slack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
238
- if (delta > slack || delta < -slack) { lost(box, anchor); return; }
387
+ if (!unbounded && (delta > slack || delta < -slack)) { lost(box, anchor); return; }
239
388
  // Sub-pixel noise is not a jump, and writing scrollTop for it costs a
240
389
  // scroll event (and a re-layout) on every settle.
390
+ var want = box.scrollTop + delta;
241
391
  if (delta >= 1 || delta <= -1) box.scrollTop += delta;
392
+ wroteTop = box.scrollTop;
393
+ restorePinned = true;
394
+ restoreExact = box.scrollTop >= want - 1 && box.scrollTop <= want + 1;
242
395
  // This position is now the reader's place, and hold() has to know it:
243
396
  // a bracketed restore MOVES scrollTop, which is exactly what hold()
244
397
  // reads as "someone scrolled, my anchor is stale". Without this, every
@@ -248,10 +401,39 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
248
401
  held = {
249
402
  key: anchor.key, top: anchor.top, pos: anchor.pos,
250
403
  scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
404
+ // Carried, not dropped: one successful restore used to disarm the
405
+ // standbys for every later hold.
406
+ alts: anchor.alts,
251
407
  };
252
408
  return;
253
409
  }
254
410
  // The anchor row is gone: its group collapsed, or the history was replaced.
411
+ // A standby row that IS still there beats lost()'s guess outright.
412
+ var alts = anchor.alts;
413
+ for (var ai = 0; alts && ai < alts.length; ai++) {
414
+ var alt = alts[ai];
415
+ var ael = findRow(box, { key: alt.key, top: alt.top, pos: alt.pos, scrollTop: anchor.scrollTop, scrollHeight: anchor.scrollHeight, el: alt.el });
416
+ if (!ael) continue;
417
+ if (alt.pos !== null && alt.pos !== UNKNOWN_ROW_POS) {
418
+ var altLive = ael.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
419
+ if (altLive !== UNKNOWN_ROW_POS && altLive !== alt.pos) continue;
420
+ }
421
+ var aboxTop = box.getBoundingClientRect().top;
422
+ var adelta = (ael.getBoundingClientRect().top - aboxTop) - alt.top;
423
+ var aslack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
424
+ if (!unbounded && (adelta > aslack || adelta < -aslack)) continue;
425
+ var awant = box.scrollTop + adelta;
426
+ if (adelta >= 1 || adelta <= -1) box.scrollTop += adelta;
427
+ wroteTop = box.scrollTop;
428
+ restorePinned = true;
429
+ restoreExact = box.scrollTop >= awant - 1 && box.scrollTop <= awant + 1;
430
+ held = {
431
+ key: alt.key, top: alt.top, pos: alt.pos,
432
+ scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: ael,
433
+ alts: alts.slice(ai + 1),
434
+ };
435
+ return;
436
+ }
255
437
  lost(box, anchor);
256
438
  }
257
439
 
@@ -272,8 +454,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
272
454
  function lost(box: AnchorBoxEl, anchor: RowAnchor): void {
273
455
  held = null;
274
456
  var grew = box.scrollHeight - anchor.scrollHeight;
275
- if (grew > 0) { box.scrollTop = anchor.scrollTop + grew; return; }
276
- if (options.rawFallback) box.scrollTop = anchor.scrollTop;
457
+ if (grew > 0) { box.scrollTop = anchor.scrollTop + grew; wroteTop = box.scrollTop; return; }
458
+ if (options.rawFallback) { box.scrollTop = anchor.scrollTop; wroteTop = box.scrollTop; }
277
459
  }
278
460
 
279
461
  function preserve<T>(mutate: () => T): T {
@@ -284,10 +466,25 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
284
466
  }
285
467
 
286
468
  function remember(): void {
469
+ // THE retirement rule for an armed return, and the only one. This runs from
470
+ // the host's scroll handler, which sees every scroll event; a position this
471
+ // module did not write is the reader, and the reader always wins. Checked
472
+ // before the frozen bail because it is the one thing allowed to end a return.
473
+ var b0 = options.getBox();
474
+ if (returning && b0 && b0.scrollTop !== wroteTop) release();
475
+ // A scroll event while the tab is hidden is not the reader moving.
476
+ if (frozen()) return;
287
477
  held = capture();
288
478
  }
289
479
 
290
480
  function hold(): void {
481
+ if (frozen()) return;
482
+ // Coming out of a FROZEN stretch, and only that. An armed return is settled
483
+ // by the host, from its focus/visible handler and the refresh settles that
484
+ // follow — never from here. hold() runs on every render, including the many
485
+ // that happen WHILE the reader is still away, and letting those settle the
486
+ // return spent it before they were back to see it.
487
+ if (sawFrozen) { settleReturn(); return; }
291
488
  var box = options.getBox();
292
489
  if (!box || options.isStuck()) { held = null; return; }
293
490
  if (!held) { held = capture(); return; }
@@ -329,11 +526,16 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
329
526
  if (!box) return;
330
527
  var h = el.offsetHeight;
331
528
  var prev = seen ? seen.get(el) : undefined;
529
+ // The baseline is recorded even while frozen (and even while pinned to the
530
+ // bottom): what must not happen is the WRITE. An image that decodes in a
531
+ // hidden tab has still resized, and forgetting that would make the next
532
+ // visible change pay for its whole height.
332
533
  if (seen) seen.set(el, h);
333
534
  if (options.isStuck()) return;
334
535
  if (prev === undefined) prev = 0;
335
536
  var delta = h - prev;
336
537
  if (delta === 0) return;
538
+ if (frozen()) { foldFrozenGrowth(box, el, delta); return; }
337
539
  // The element's own TOP, which a resize never moves: everything BELOW it
338
540
  // slides by delta, everything above stays. So the reader's first visible
339
541
  // line moved exactly when that top is above the fold — whether the element
@@ -342,6 +544,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
342
544
  // line the reader is looking at, and moving them would be the jump.
343
545
  if (el.getBoundingClientRect().top >= box.getBoundingClientRect().top) return;
344
546
  box.scrollTop += delta;
547
+ wroteTop = box.scrollTop;
345
548
  // Re-measure the remembered anchor, do not patch it. Its scrollTop is
346
549
  // stale after that write (hold() would read the difference as "the reader
347
550
  // scrolled" and throw the anchor away), and so is its offset whenever the
@@ -351,8 +554,153 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
351
554
  if (held) held = capture();
352
555
  }
353
556
 
557
+ /**
558
+ * The tab has come forward. Put the reader back on the line they left, whatever
559
+ * scrollTop says now.
560
+ *
561
+ * hold() cannot do this job. Its safety rule is "the anchor is valid only while
562
+ * box.scrollTop still equals the value it was captured at", which is what stops
563
+ * it dragging a reader back after they scroll themselves — and across a hidden
564
+ * stretch that rule points the wrong way. Plenty of things write scrollTop while
565
+ * the tab is hidden without going through this module at all (a settling request
566
+ * scrolling to the bottom, the browser clamping after a refresh shortened the
567
+ * list), so on return the value has moved and hold() concludes the reader moved
568
+ * it and gives up — leaving them wherever the last invisible write landed. That
569
+ * IS the "somehow placed in the middle".
570
+ *
571
+ * Nothing that happened while nobody was looking was the reader, so here the
572
+ * remembered place simply wins.
573
+ */
574
+ /**
575
+ * The reader is going away. Park the place they are leaving, in a slot that the
576
+ * ordinary compensation cannot overwrite.
577
+ *
578
+ * Not the same thing as freezing. A tab that goes HIDDEN stops being painted, so
579
+ * there is nothing to compensate and writing scrollTop is pointless. But a window
580
+ * that merely loses focus — the user switched to another application — is usually
581
+ * still `document.visibilityState === 'visible'`: nothing fires visibilitychange,
582
+ * the page keeps rendering, and the chat keeps mutating underneath a reader who
583
+ * is not there. Compensation must keep running for that case (they may still be
584
+ * able to SEE the window, on a second monitor or beside the other app), which is
585
+ * exactly why `held` cannot be trusted on return: every background correction
586
+ * re-stamps it. So the leaving place is parked separately.
587
+ */
588
+ function park(): void {
589
+ parked = capture();
590
+ parkedStuck = !!options.isStuck();
591
+ returning = true;
592
+ returnBudget = MAX_RETURN_SETTLES;
593
+ }
594
+
595
+ /** The reader is driving: an armed return is no longer anyone's business. */
596
+ function release(): void {
597
+ parked = null;
598
+ parkedStuck = false;
599
+ returning = false;
600
+ returnBudget = 0;
601
+ }
602
+
603
+ /**
604
+ * Pin to the bottom, instantly, recording the write.
605
+ *
606
+ * The ONE way anything is allowed to pin. Instant because a smooth glide fires a
607
+ * scroll event per frame at positions that are not the bottom, and the hosts
608
+ * clear stickToBottom on each of them — so any merge landing inside the ~130ms
609
+ * animation strands the reader off the bottom permanently, aiming at a target
610
+ * that was already stale when the glide started. Recorded because an unrecorded
611
+ * write looks like the reader and would retire an armed return early.
612
+ */
613
+ function pinBottom(): void {
614
+ var box = options.getBox();
615
+ if (!box) return;
616
+ box.scrollTop = box.scrollHeight;
617
+ wroteTop = box.scrollTop;
618
+ }
619
+
620
+ /**
621
+ * The reader is back. Put them on the parked place, whatever scrollTop says now.
622
+ *
623
+ * hold() cannot do this job. Its safety rule is "the anchor is valid only while
624
+ * box.scrollTop still equals the value it was captured at", which is what stops
625
+ * it dragging a reader back after they scroll themselves — and across an absence
626
+ * that rule points the wrong way. Plenty of things write scrollTop while nobody
627
+ * is looking (a settling request scrolling to the bottom, the browser clamping
628
+ * after a refresh shortened the list), so on return the value has moved and
629
+ * hold() concludes the reader moved it and gives up — leaving them wherever the
630
+ * last unwatched write landed. That IS the "somehow placed in the middle".
631
+ *
632
+ * Nothing that happened while they were away was them, so the parked place wins.
633
+ */
634
+ function settleReturn(): boolean {
635
+ if (frozen()) return false;
636
+ var wasFrozen = sawFrozen;
637
+ sawFrozen = false;
638
+ // Nothing is armed, so there is nothing to settle. Without this the call was
639
+ // a licence to re-impose `held` UNBOUNDED at any time — which is exactly how
640
+ // a reader who had already taken control got thrown back: they released the
641
+ // return, and the next background settle simply used the live anchor instead.
642
+ if (!returning && !wasFrozen) return false;
643
+ if (returning) {
644
+ if (returnBudget <= 0) { release(); return false; }
645
+ returnBudget--;
646
+ }
647
+ // A reader who left pinned has no row to hold; the bottom is the place. Stay
648
+ // armed so the NEXT settle re-pins after the deferred batch merges, which is
649
+ // the bottom they actually mean.
650
+ if (parkedStuck) { pinBottom(); return true; }
651
+ var target = parked || held;
652
+ restoreExact = true; restorePinned = false;
653
+ restore(target, true);
654
+ // Keep the parked place when the correction could not land. A phase-1 shrink
655
+ // leaves the list at its shortest, so the browser truncates the write and the
656
+ // reader ends up short — and phase 2 re-grows the list, making that same
657
+ // place reachable again. Believing the clamped write was a success is what
658
+ // made the error permanent.
659
+ parked = (!restorePinned || !restoreExact) ? target : null;
660
+ returning = !!parked && returnBudget > 0;
661
+ if (!returning) { parked = null; parkedStuck = false; returnBudget = 0; }
662
+ return false;
663
+ }
664
+
665
+ function isReturning(): boolean { return returning; }
666
+
667
+ /** Deprecated alias. */
668
+ function thaw(): void { settleReturn(); }
669
+
670
+ /**
671
+ * An element resized while nobody was looking. Fold it into the remembered
672
+ * places instead of dropping it.
673
+ *
674
+ * The live box cannot be measured against here: while frozen every compensator
675
+ * no-ops, so a prepend or a clamp may have moved the box out from under the
676
+ * offsets these anchors were taken at. Judge it in the ANCHOR's own frame
677
+ * instead — is this element inside the anchored row, and above the reader's
678
+ * line — and adjust the anchor rather than the scroll.
679
+ *
680
+ * `held` and `parked` can be different rows, so each is folded separately. The
681
+ * standbys are deliberately not touched: a row below the growth is re-measured
682
+ * by restore() anyway.
683
+ */
684
+ function foldFrozenGrowth(box: AnchorBoxEl, el: AnchorGrowableEl, delta: number): void {
685
+ var elTop = el.getBoundingClientRect().top;
686
+ foldInto(box, held, elTop, delta);
687
+ foldInto(box, parked, elTop, delta);
688
+ }
689
+
690
+ function foldInto(box: AnchorBoxEl, a: RowAnchor | null, elTop: number, delta: number): void {
691
+ if (!a || a.top >= 0) return; // the row starts at or below the fold
692
+ var rowEl = findRow(box, a);
693
+ if (!rowEl) return;
694
+ var within = elTop - rowEl.getBoundingClientRect().top;
695
+ if (within < 0 || within >= rowEl.offsetHeight) return; // outside, or stale
696
+ if (within >= -a.top) return; // at or below the reader's own line
697
+ a.top -= delta;
698
+ a.scrollHeight += delta;
699
+ }
700
+
354
701
  function forget(): void {
355
702
  held = null;
703
+ release();
356
704
  }
357
705
 
358
706
  return {
@@ -361,6 +709,13 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
361
709
  preserve: preserve,
362
710
  remember: remember,
363
711
  hold: hold,
712
+ park: park,
713
+ settleReturn: settleReturn,
714
+ isReturning: isReturning,
715
+ release: release,
716
+ pinBottom: pinBottom,
717
+ isFrozen: frozen,
718
+ thaw: thaw,
364
719
  absorb: absorb,
365
720
  forget: forget,
366
721
  };
@@ -3803,6 +3803,12 @@ export class ChatSession {
3803
3803
  releaseBgFlag();
3804
3804
  self.updateHistoryCache();
3805
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();
3806
3812
  }, function () {
3807
3813
  // Stubs missing until the next refresh re-asks; never fatal.
3808
3814
  releaseBgFlag();
@@ -3932,7 +3938,11 @@ export class ChatSession {
3932
3938
  // the one resumePolling fires on visibilitychange. Forcing yanked a
3933
3939
  // reader who had scrolled up back to the bottom. On a genuine mount the
3934
3940
  // user is still pinned, so this behaves identically there.
3935
- 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();
3936
3946
  }).catch(function (err: any) {
3937
3947
  console.warn('[chat-engine] getChatHistory failed', err);
3938
3948
  }).then(function () {